覆盖

MEAP 版本 3

MEAP VERSION 3













曼宁出版公司

关于这个 MEAP

About this MEAP

您可以从account.manning.com上的 Manning 帐户下载最新版本的电子书。如需客户支持,请写信至support@manning.com

You can download the most up-to-date version of your electronic books from your Manning Account at account.manning.com. For customer support write to support@manning.com.

© Manning Publications Co. 我们欢迎读者对手稿中的任何内容提出意见 - 除了拼写错误和其他简单的错误。这些将在本书制作过程中由文案编辑和校对人员清理。

© Manning Publications Co. We welcome reader comments about anything in the manuscript - other than typos and other simple mistakes. These will be cleaned up during production of the book by copyeditors and proofreaders.

https://livebook.manning.com/#!/book/unit-testing/discussion

https://livebook.manning.com/#!/book/unit-testing/discussion

欢迎

Welcome

感谢您购买 MEAP of Unit Testing: Principles, Practices, and Patterns

Thank you for purchasing the MEAP of Unit Testing: Principles, Practices, and Patterns.

大多数在线和印刷资源都有一个缺点:它们侧重于单元测试的基础知识,但并没有超出这一点。这些资源有很多价值,但学习并没有就此结束。还有一个新的层次:不仅仅是编写测试,而是以一种能给你的努力带来最好回报的方式来做。当您到达学习曲线的这一点时,您几乎只能依靠自己的设备来弄清楚如何进入下一个级别。

Most online and print resources have one drawback: they focus on the basics of unit testing, but don’t go much beyond that. There’s a lot of value in such resources, but the learning doesn't end there. There's a next level: not just writing tests, but doing it in a way that gives you the best return on your efforts. When you reach this point in the learning curve, you’re pretty much left to your own devices figuring out how to get to that next level.

本书将带您进入下一个层次。它教导了理想单元测试的科学、精确的定义。这个定义提供了一个通用的参考框架。学习它之后,您将能够以新的眼光看待您的许多测试,并了解它们中的哪些对项目有贡献,哪些必须重构或完全删除。

This book takes you to that next level. It teaches a scientific, precise definition of the ideal unit test. This definition provides a universal frame of reference. After learning it, you will be able to look at many of your tests in a new light and see which of them contribute to the project and which must be refactored or gotten rid of altogether.

如果您是一位经验丰富的程序员,您很可能已经在直观层面上了解了本书中教授的一些思想。本书将帮助您阐明为什么您一直使用的技术和最佳实践如此有用。不要低估这项技能:向同事清楚地传达您的想法的能力是无价的。

If you’re an experienced programmer, you most likely already get at the intuitive level some of the ideas taught in this book. This book will help you articulate why the techniques and best practices you've been using all along are so helpful. And don't underestimate this skill: the ability to clearly communicate your ideas to colleagues is priceless.

你不需要成为单元测试方面的专家,但如果你有一些单元测试技能,你将从本书中获益更多。如果您在单元测试技术和最佳实践方面没有太多经验,您会学到很多东西。除了可以用来分析测试套件中的任何测试的参考框架之外,本书还教授

You don’t need to be an expert in unit testing, though you’ll get more out of this book if you have some unit testing skills. If you don't have much experience with unit testing techniques and best practices, you’ll learn a lot. In addition to the frame of reference, which you can use to analyze any test in a test suite, the book teaches

  • 如何重构测试套件及其涵盖的生产代码。
  • How to refactor the test suite along with the production code it covers.
  • 如何应用不同风格的单元测试。
  • How to apply different styles of unit testing.
  • 使用集成测试来验证整个系统的行为。
  • Using integration tests to verify the behavior of the system as a whole.
  • 识别和避免单元测试中的反模式。
  • Identifying and avoiding anti-patterns in unit tests.

除了单元测试之外,本书涵盖了自动化测试的整个主题,因此您还将了解集成和端到端测试。

In addition to unit tests, this book covers the entire topic of automated testing, so you’ll also learn about integration and end-to-end tests.

您的反馈对于创作最好的书至关重要。请务必在liveBook 讨论论坛中发布您对本书的任何问题、评论或建议。

Your feedback is essential to creating the best book possible. Please be sure to post any questions, comments, or suggestions you have about the book in the liveBook Discussion Forum.

——弗拉基米尔·霍里科夫

—Vladimir Khorikov

第1部分

Part 1

大局观

The bigger picture

本书的这一部分将使您快速了解单元测试的当前状态。

This part of the book will get you up to speed with the current state of unit testing.

在第 1 章中,我将定义单元测试的目标,并概述如何区分好的测试和坏的测试。我们将讨论覆盖率指标并讨论良好单元测试的一般属性。

In chapter 1, I’ll define the goal of unit testing and give an overview of how to differentiate a good test from a bad one. We’ll talk about coverage metrics and discuss properties of a good unit test in general.

在第 2 章中,我们将了解“单元测试”的定义。这个定义中看似很小的分歧导致了单元测试的两个流派的形成,我们也将在该章中深入探讨。

In chapter 2, we’ll look at the definition of "unit test". A seemingly minor disagreement in this definition has led to formation of two schools of unit testing, which we’ll dive into in that chapter too.

第 3 章回顾了一些基本主题,例如单元测试的结构、重用测试夹具和测试参数化。

Chapter 3 provides a refresher on some basic topics, such as structuring of unit tests, reusing test fixtures, and test parametrization.

1

1

单元测试的目标

The goal of unit testing

本章涵盖:

This chapter covers:

  • 单元测试的状态

  • The state of unit testing

  • 单元测试的目标

  • The goal of unit testing

  • 测试套件不好的后果

  • Consequences of having a bad test suite

  • 使用覆盖率度量来衡量测试套件的质量

  • Using coverage metrics to measure test suite quality

  • 成功测试套件的属性

  • Attributes of a successful test suite

学习单元测试并不止于掌握它的技术细节,例如您最喜欢的测试框架、模拟库等。单元测试不仅仅是编写测试的行为。您始终必须努力在单元测试上投入的时间获得最佳回报,最大限度地减少您投入测试的工作并最大限度地提高它们提供的好处。实现这两点并非易事。

Learning unit testing doesn’t stop at mastering the technical bits of it, such as your favorite test framework, mocking library, and so on. There’s much more to unit testing than the act of writing tests. You always have to strive to achieve the best return on the time you invest in unit testing, minimizing the effort you put into tests and maximizing the benefits they provide. Achieving both things isn’t an easy task.

观察实现这种平衡的项目非常有趣:它们可以毫不费力地增长,不需要太多维护,并且可以快速适应客户不断变化的需求。看到失败的项目同样令人沮丧。尽管付出了所有努力并进行了大量单元测试,但此类项目进展缓慢,存在大量错误和维护成本。

It’s fascinating to watch projects that have achieved this balance: they grow effortlessly, don’t require much maintenance, and can quickly adapt to their customers' ever-changing needs. It’s equally frustrating to see projects that failed to do so. Despite all the effort and an impressive number of unit tests, such projects drag on slowly, with lots of bugs and upkeep costs.

这就是各种单元测试技术之间的区别。有些会产生很好的结果并有助于保持软件质量。其他人则不会:它们导致测试贡献不大,经常中断,并且通常需要大量维护。

That’s the difference between various unit testing techniques. Some yield great outcomes and help maintain software quality. Others don’t: they result in tests that don’t contribute much, break often, and require a lot of maintenance in general.

您在本书中学到的内容将帮助您区分好的和坏的单元测试技术。您将学习如何对测试进行成本效益分析,并在您的特定情况下应用适当的测试技术。您还将学习如何避免常见的反模式——一开始可能有意义但会导致麻烦的模式。

What you learn in this book will help you differentiate between good and bad unit testing techniques. You’ll learn how to do a cost-benefit analysis of your tests and apply proper testing techniques in your particular situation. You’ll also learn how to avoid common anti-patterns — patterns that may make sense at first but lead to trouble down the road.

但让我们从基础开始。本章简要概述了软件行业中单元测试的状态,描述了编写和维护测试背后的目标,并为您提供了使测试套件成功的原因。

But let’s start with the basics. This chapter gives a quick overview of the state of unit testing in the software industry, describes the goal behind writing and maintaining tests, and provides you with the idea of what makes a test suite successful.

1.1 单元测试现状

1.1  The current state of unit testing

在本节中,我将高度概述为什么单元测试是一件好事,以及单元测试是如何发展的。在过去的二十年里,一直在推动采用单元测试。这一推动非常成功,以至于现在大多数公司都认为单元测试是强制性的。大多数程序员都会进行单元测试并了解其重要性。关于您是否应该这样做,不再有任何争议。除非你在做一个一次性项目,否则答案是:你做的。

In this section, I give a high-level overview of why unit testing is a good thing, and how unit testing is evolving. For the past two decades, there’s been a push toward adopting unit testing. The push has been so successful that unit testing is now considered mandatory in most companies. Most programmers practice unit testing and understand its importance. There’s no longer any dispute as to whether you should do it. Unless you’re working on a throw-away project, the answer is: you do.

在企业应用程序开发方面,几乎每个项目都至少包含一些单元测试。此类项目中的很大一部分远远不止于此:它们通过大量的单元和集成测试实现了良好的代码覆盖率。生产代码和测试代码之间的比例可以在 1:1 和 1:3 之间的任何地方(对于每一行生产代码,有一到三行测试代码)。有时,这个比例甚至比那个高得多,达到惊人的 1:10。

When it comes to enterprise application development, almost every project includes at least some unit tests. A significant percentage of such projects go far beyond that: they achieve good code coverage with lots and lots of unit and integration tests. The ratio between the production code and the test code could be anywhere between 1:1 and 1:3 (for each line of production code, there are one to three lines of test code). Sometimes, this ratio even goes much higher than that, to a whopping 1:10.

但是,与所有新技术一样,单元测试也在不断发展。讨论已经从“我们应该编写单元测试吗?”转变为“我们应该编写单元测试吗?” 到“编写好的单元测试意味着什么?” 这是主要的困惑仍然存在的地方。

But, as with all new technologies, unit testing continues to evolve. The discussion has shifted from "Should we write unit tests?" to "What does it mean to write good unit tests?" This is where the main confusion still lies.

您可以在软件项目中看到这种混淆的结果。许多项目都有自动化测试;他们甚至可能有很多。但这些测试的存在往往不能提供开发人员希望的结果。在这样的项目中,程序员仍然需要付出很多努力才能取得进展。新功能需要永远实施,新错误不断出现在已经实施和接受的功能中,而本应有所帮助的单元测试似乎根本无法缓解这种情况。他们甚至可以使情况变得更糟。

You can see the results of this confusion in software projects. Many projects have automated tests; they may even have a lot of them. But the existence of those tests often doesn’t provide the results the developers hope for. It can still take programmers a lot of effort to make progress in such projects. New features take forever to implement, new bugs constantly appear in the already implemented and accepted functionality, and the unit tests that are supposed to help don’t seem to mitigate this situation at all. They can even make it worse.

对于任何人来说,这都是一种可怕的情况——这是单元测试没有正确完成其工作的结果。好的和坏的测试之间的区别不仅仅是品味或个人喜好的问题,而是你正在从事的这个关键项目的成功或失败的问题。

It’s a horrible situation for anyone to be in — and it’s the result of having unit tests that don’t do their job properly. The difference between good and bad tests is not merely a matter of taste or personal preference, it’s a matter of succeeding or failing at this critical project you’re working on.

很难高估讨论什么是好的单元测试的重要性。尽管如此,这种讨论在当今的软件开发行业中并不多见。您会在网上找到一些文章和会议演讲,但我还没有看到关于这个主题的任何综合材料。

It’s hard to overestimate the importance of the discussion of what makes a good unit test. Still, this discussion isn’t occurring much in the software development industry today. You’ll find a few articles and conference talks online, but I’ve yet to see any comprehensive material on this topic.

书中的情况也好不到哪去;他们中的大多数人都专注于单元测试的基础知识,但并没有超出此范围。不要误会我的意思。这些书籍有很多价值,尤其是当您刚开始进行单元测试时。但是,学习并不仅限于基础知识。还有一个新的层次:不仅仅是编写测试,而是以一种为您的努力提供最佳回报的方式进行单元测试。当您达到这一点时,大多数书籍几乎都会让您自行决定如何进入下一个级别。

The situation in books isn’t any better; most of them focus on the basics of unit testing but don’t go much beyond that. Don’t get me wrong. There’s a lot of value in such books, especially when you are just starting out with unit testing. However, the learning doesn’t end with the basics. There’s a next level: not just writing tests, but doing unit testing in a way that provides you with the best return on your efforts. When you reach this point, most books pretty much leave you to your own devices, figuring out how to get to that next level.

这本书带你去那里。它教导了理想单元测试的精确、科学的定义。您将看到如何将此定义应用于实际的、真实世界的示例。我希望这本书能帮助您理解为什么您的特定项目尽管进行了大量测试仍可能出现问题,以及如何更好地纠正其过程。

This book takes you there. It teaches a precise, scientific definition of the ideal unit test. You’ll see how this definition can be applied to practical, real-world examples. My hope is that this book will help you understand why your particular project may have gone sideways despite having a good number of tests, and how to correct its course for the better.

如果您从事企业应用程序开发,您将从本书中获得最大价值,但核心思想适用于任何软件项目。

You’ll get the most value out of this book if you work in enterprise application development, but the core ideas are applicable to any software project.

什么是企业应用程序?

What is an enterprise application?

企业应用程序是旨在自动化或协助组织内部流程的应用程序。它可以有多种形式,但通常企业软件的特征是

An enterprise application is an application that aims at automating or assisting an organization’s inner processes. It can take many forms, but usually the characteristics of an enterprise software are

  • 业务逻辑复杂度高

  • High business logic complexity

  • 项目生命周期长

  • Long project lifespan

  • 中等数据量

  • Moderate amounts of data

  • 低或中等性能要求

  • Low or moderate performance requirements

1.2 单元测试的目标

1.2  The goal of unit testing

在深入探讨单元测试主题之前,让我们退后一步,考虑一下单元测试可以帮助您实现的目标。人们常说单元测试实践会带来更好的设计。这是真的:为代码库编写单元测试的必要性通常会导致更好的设计。但这不是单元测试的主要目标;这只是一个令人愉快的副作用。

Before taking a deep dive into the topic of unit testing, let’s step back and consider the goal that unit testing helps you to achieve. It’s often said that unit testing practices lead to a better design. And it’s true: the necessity to write unit tests for a code base normally leads to a better design. But that’s not the main goal of unit testing; it’s merely a pleasant side effect.

单元测试与代码设计的关系

The relationship between unit testing and code design

对一段代码进行单元测试的能力是一个很好的试金石,但它只在一个方向上有效。这是一个很好的负面指标——它以相对较高的准确性指出质量较差的代码。如果您发现代码难以进行单元测试,则表明该代码需要改进。质量差通常表现为紧耦合,这意味着不同的生产代码段之间的解耦不够,很难单独测试它们。

The ability to unit test a piece of code is a nice litmus test, but it only works in one direction. It’s a good negative indicator — it points out poor-quality code with relatively high accuracy. If you find that code is hard to unit test, it’s a strong sign that the code needs improvement. The poor quality usually manifests itself in tight coupling, which means different pieces of production code are not decoupled from each other enough, and it’s hard to test them separately.

不幸的是,对一段代码进行单元测试的能力是一个不好的积极指标。您可以轻松地对您的代码库进行单元测试这一事实并不一定意味着它质量很好。即使项目表现出高度脱钩,该项目也可能是一场灾难。

Unfortunately, the ability to unit test a piece of code is a bad positive indicator. The fact that you can easily unit test your code base doesn’t necessarily mean it’s of good quality. The project can be a disaster even when it exhibits a high degree of decoupling.

那么,单元测试的目标是什么?目标是实现软件项目的可持续增长。可持续一词是关键。发展项目非常容易,尤其是当您从头开始时。随着时间的推移,要维持这种增长要困难得多。

What is the goal of unit testing, then? The goal is to enable sustainable growth of the software project. The term sustainable is key. It’s quite easy to grow a project, especially when you start from scratch. It’s much harder to sustain this growth over time.

1.1显示了一个没有测试的典型项目的增长动态。你很快就开始了,因为没有什么能拖累你。还没有做出糟糕的架构决策,也没有任何现有代码需要担心。然而,随着时间的流逝,您必须投入越来越多的时间才能取得与开始时相同的进步。最终,开发速度明显减慢,有时甚至无法取得任何进展。

Figure 1.1 shows the growth dynamic of a typical project without tests. You start off quickly because there’s nothing dragging you down. No bad architectural decisions have been made yet, and there isn’t any existing code to worry about. As time goes by, however, you have to put in more and more hours to make the same amount of progress you showed at the beginning. Eventually, the development speed slows down significantly, sometimes even to the point when you can’t make any progress whatsoever.

图 1.1。有测试和没有测试的项目之间的增长动力差异。一个没有测试的项目有一个良好的开端,但很快就会减速到难以取得任何进展的程度。

Figure 1.1. The difference in growth dynamics between projects with and without tests. A project without tests has a head start but quickly slows down to the point that it’s hard to make any progress.

CH01 图1 有无测试

这种开发速度迅速下降的现象也称为软件熵。熵(系统中的混乱程度)是一个数学和科学概念,也可以应用于软件系统。(如果您对熵的数学和科学感兴趣,请查阅热力学第二定律。)

This phenomenon of quickly decreasing development speed is also known as software entropy. Entropy (the amount of disorder in a system) is a mathematical and scientific concept that can also apply to software systems. (If you’re interested in the math and science of entropy, look up the second law of thermodynamics.)

在软件中,熵以易于恶化的代码形式出现。每次您更改代码库中的某些内容时,其中的混乱程度或熵都会增加。如果没有适当的照顾,比如不断的清理和重构,系统就会变得越来越复杂和混乱。修复一个错误会引入更多错误,修改软件的一部分会破坏其他几个部分——这就像多米诺骨牌效应。最终,代码库变得不可靠。最糟糕的是,很难让它恢复稳定。

In software, entropy manifests in the form of code that tends to deteriorate. Each time you change something in a code base, the amount of disorder in it, or entropy, increases. If left without proper care, such as constant cleaning and refactoring, the system becomes increasingly complex and disorganized. Fixing one bug introduces more bugs, and modifying one part of the software breaks several others — it’s like a domino effect. Eventually, the code base becomes unreliable. And worst of all, it’s hard to bring it back to stability.

测试有助于推翻这种趋势。它们充当安全网——一种为绝大多数回归提供保险的工具。测试有助于确保现有功能正常工作,即使在您引入新功能或重构代码以更好地满足新需求之后也是如此。

Tests help overturn this tendency. They act as a safety net — a tool that provides insurance against a vast majority of regressions. Tests help make sure the existing functionality works, even after you introduce new features or refactor the code to better fit new requirements.

 

 

[笔记] 笔记

回归是指某个功能在特定事件(通常是代码修改)后停止按预期工作术语回归软件错误是同义词,可以互换使用。

A regression is when a feature stops working as intended after a certain event (usually, a code modification). The terms regression and software bug are synonyms and can be used interchangeably.

这里的缺点是测试需要初始的,有时是显着的努力。但从长远来看,他们会通过帮助项目在后期阶段成长来收回成本。没有不断验证代码库的测试帮助的软件开发根本无法扩展。

The downside here is that tests require initial, sometimes significant, effort. But they pay for themselves in the long run by helping the project to grow in the later stages. Software development without the help of tests that constantly verify the code base simply doesn’t scale.

可持续性和可扩展性是关键。从长远来看,它们可以让您保持开发速度。

Sustainability and scalability are the keys. They allow you to maintain development speed in the long run.

1.2.1 什么是好的或坏的测试?

1.2.1  What makes a good or bad test?

尽管单元测试有助于保持项目增长,但仅仅编写测试是不够的。写得不好的测试仍然会产生相同的结果。

Although unit testing helps maintain project growth, it’s not enough to just write tests. Badly written tests still result in the same picture.

如图1.2所示,糟糕的测试在一开始确实有助于减缓代码恶化:与完全没有测试的情况相比,开发速度的下降并不那么明显。但事情的宏伟计划并没有真正改变。这样的项目进入停滞阶段可能需要更长时间,但停滞仍然是不可避免的。

As shown in figure 1.2, bad tests do help to slow down code deterioration at the beginning: the decline in development speed is less prominent compared to the situation with no tests at all. But nothing really changes in the grand scheme of things. It might take longer for such a project to enter the stagnation phase, but stagnation is still inevitable.

图 1.2。具有良好和不良测试的项目之间的增长动力差异。一个测试写得不好的项目在开始时表现出测试良好的项目的属性,但最终会陷入停滞阶段。

Figure 1.2. The difference in growth dynamics between projects with good and bad tests. A project with badly written tests exhibits the properties of a project with good tests at the beginning, but it eventually falls into the stagnation phase.

CH01 图2 好的坏的测试

请记住,并非所有测试都生而平等。其中一些很有价值,对整体软件质量有很大贡献。其他人没有。它们会发出错误警报,不会帮助您捕获回归错误,而且速度慢且难以维护。在不清楚它是否对项目有帮助的情况下,很容易陷入为了单元测试而编写单元测试的陷阱。

Remember, not all tests are created equal. Some of them are valuable and contribute a lot to overall software quality. Others don’t. They raise false alarms, don’t help you catch regression errors, and are slow and difficult to maintain. It’s easy to fall into the trap of writing unit tests for the sake of unit testing without a clear picture of whether it helps the project.

仅仅在项目中投入更多的测试是无法达到单元测试的目的的。您需要同时考虑测试的价值和维护成本。成本构成取决于在各种活动上花费的时间:

You can’t achieve the goal of unit testing by just throwing more tests at the project. You need to consider both the test’s value and its upkeep cost. The cost component is determined by the amount of time spent on various activities:

  • 在重构底层代码时重构测试

  • Refactoring the test when you refactor the underlying code

  • 在每次代码更改时运行测试

  • Running the test on each code change

  • 处理测试引发的误报

  • Dealing with false alarms raised by the test

  • 当您试图了解底层代码的行为方式时,花时间阅读测试

  • Spending time reading the test when you’re trying to understand how the underlying code behaves

由于高昂的维护成本,很容易创建净值接近于零甚至为负的测试。为了实现可持续的项目增长,您必须专注于高质量的测试——这些是唯一值得保留在测试套件中的测试类型。

It’s easy to create tests whose net value is close to zero or even is negative due to high maintenance costs. To enable sustainable project growth, you have to exclusively focus on high-quality tests — those are the only type of tests that are worth keeping in the test suite.

生产代码与测试代码

Production code vs. test code

人们通常认为生产代码和测试代码是不同的。假设测试是对没有拥有成本的生产代码的补充。推而广之,人们通常认为测试越多越好。

People often think production code and test code are different. Tests are assumed to be an addition to production code that has no cost of ownership. By extension, people often believe that the more tests, the better.

事实并非如此。代码是一种责任,而不是一种资产。引入的代码越多,软件中潜在错误的表面积就越大,项目的维护成本就越高。用尽可能少的代码解决问题总是更好。

This isn’t the case. Code is a liability, not an asset. The more code you introduce, the more you extend the surface area for potential bugs in your software, and the higher the project’s upkeep cost. It’s always better to solve problems with as little code as possible.

测试也是代码。您应该将它们视为旨在解决特定问题的代码库的一部分:确保应用程序的正确性。与任何其他代码一样,单元测试也容易出现错误并需要维护。

Tests are code, too. You should view them as the part of your code base that aims at solving a particular problem: ensuring the application’s correctness. Unit tests, just like any other code, are also vulnerable to bugs and require maintenance.

学习如何区分好的和坏的单元测试是至关重要的。我在第 4 章中介绍了这个主题。

It’s crucial to learn how to differentiate between good and bad unit tests. I cover this topic in chapter 4.

1.3 使用覆盖率指标衡量测试套件质量

1.3  Using coverage metrics to measure test suite quality

在本节中,我将讨论两个最流行的覆盖率指标——代码覆盖率和分支覆盖率——如何计算它们、如何使用它们以及它们存在的问题。我将说明为什么程序员瞄准特定的覆盖率数字是有害的,以及为什么不能仅仅依靠覆盖率指标来确定测试套件的质量。

In this section, I talk about the two most popular coverage metrics — code coverage and branch coverage — how to calculate them, how they’re used, and problems with them. I’ll show why it’s detrimental for programmers to aim at a particular coverage number and why you can’t just rely on coverage metrics to determine the quality of your test suite.

 

 

[笔记] 笔记

覆盖率指标显示测试套件执行了多少源代码,从无到 100%。

A coverage metric shows how much source code a test suite executes, from none to 100%.

有不同类型的覆盖指标,它们通常用于评估测试套件的质量。人们普遍认为覆盖率越高越好。

There are different types of coverage metrics, and they’re often used to assess the quality of a test suite. The common belief is that the higher the coverage number, the better.

不幸的是,它并没有那么简单,覆盖率指标虽然提供有价值的反馈,但不能用于有效衡量测试套件的质量。这与对代码进行单元测试的情况相同:覆盖率指标是一个很好的负面指标,但不是一个积极的指标。

Unfortunately, it’s not that simple, and coverage metrics, while providing valuable feedback, can’t be used to effectively measure the quality of a test suite. It’s the same situation as with the ability to unit test the code: coverage metrics are a good negative indicator but a bad positive one.

如果一个指标显示您的代码库中的覆盖率太低——比如说,只有 10%——这是一个很好的迹象,表明您没有进行足够的测试。但反之则不然:即使是 100% 的覆盖率也不能保证您拥有高质量的测试套件。提供高覆盖率的测试套件质量可能仍然很差。

If a metric shows that there’s too little coverage in your code base — say, only 10% — that’s a good indication that you are not testing enough. But the reverse isn’t true: even 100% coverage isn’t a guarantee that you have a good-quality test suite. A test suite that provides high coverage can still be of poor quality.

我已经谈到了为什么会这样——你不能只是对你的项目进行随机测试,希望这些测试会改善这种情况。但是让我们根据代码覆盖率指标详细讨论这个问题。

I already touched on why this is so — you can’t just throw random tests at your project with the hope those tests will improve the situation. But let’s discuss this problem in detail with respect to the code-coverage metric.

1.3.1 了解代码覆盖率指标

1.3.1  Understanding the code-coverage metric

第一个也是最常用的覆盖率指标是代码覆盖率,也称为测试覆盖率;见图 1.3。该指标显示至少一个测试执行的代码行数与生产代码库中总行数的比率。

The first and most-used coverage metric is code coverage, also known as test coverage; see figure 1.3. This metric shows the ratio of the number of code lines executed by at least one test and the total number of lines in the production code base.

公式 1.1:代码覆盖率(测试覆盖率)指标计算为测试套件执行的代码行数与生产代码库中的总行数之间的比率。

Equation 1.1: The code coverage (test coverage) metric is calculated as the ratio between the number of code lines executed by the test suite and the total number of lines in the production code base.

代码覆盖率(测试覆盖率)=执行的代码行数/总行数

Code coverage (test coverage) = Lines of code executed / Total number of lines

让我们看一个例子来更好地理解它是如何工作的。下面的清单显示了一个IsStringLong方法和一个涵盖它的测试。

Let’s see an example to better understand how this works. The following listing shows an IsStringLong method and a test that covers it.

该方法判断作为输入参数提供给它的字符串是否为长字符串(这里,长的定义是长度大于五个字符的任何字符串)。该测试使用该方法进行测试"abc",并检查该字符串是否被认为不长。

The method determines whether a string provided to it as an input parameter is long (here, the definition of long is any string with the length greater than five characters). The test exercises the method using "abc" and checks that this string is not considered long.

清单 1.1。测试部分覆盖的示例方法

Listing 1.1. A sample method partially covered by a test

public static bool IsStringLong(字符串输入)
{                            
    if (input.Length > 5)   
        返回真;        

    返回假;            
}                           

公共无效测试()
{
    布尔结果 = IsStringLong("abc");
    断言。等于(假,结果);
}
public static bool IsStringLong(string input)
{                           
    if (input.Length > 5)   
        return true;        

    return false;           
}                           

public void Test()
{
    bool result = IsStringLong("abc");
    Assert.Equal(false, result);
}

被测试覆盖

Covered by the test

不在测试范围内

Not covered by the test

这里很容易计算代码覆盖率。方法中的总行数是五行(大括号也算在内)。测试执行的行数为四——测试遍历除语句之外的所有代码行return true;。这给了我们 4/5 = 0.8 = 80% 的代码覆盖率。

It’s easy to calculate the code coverage here. The total number of lines in the method is five (curly braces count, too). The number of lines executed by the test is four — the test goes through all the code lines except for the return true; statement. This gives us 4/5 = 0.8 = 80% code coverage.

现在,如果我像这样重构方法并内联不必要的if语句会怎样?

Now, what if I refactor the method and inline the unnecessary if statement, like this?

public static bool IsStringLong(字符串输入)
{
    返回输入。长度 > 5;
}

公共无效测试()
{
    布尔结果 = IsStringLong("abc");
    断言。等于(假,结果);
}
public static bool IsStringLong(string input)
{
    return input.Length > 5;
}

public void Test()
{
    bool result = IsStringLong("abc");
    Assert.Equal(false, result);
}

代码覆盖数是否改变?是的,它确实。因为测试现在运行了所有三行代码(return语句加上两个大括号),所以代码覆盖率增加到 100%。

Does the code-coverage number change? Yes, it does. Because the test now exercises all three lines of code (the return statement plus two curly braces), the code coverage increases to 100%.

但是我是否通过这种重构改进了测试套件?当然不是。我只是改组了方法中的代码。该测试仍然验证相同数量的可能结果。

But did I improve the test suite with this refactoring? Of course not. I just shuffled the code inside the method. The test still verifies the same number of possible outcomes.

这个简单的例子展示了玩弄覆盖数字是多么容易。您的代码越紧凑,测试覆盖率指标就越好,因为它只考虑原始行号。同时,将更多代码压缩到更少空间不会(也不应该)改变测试套件的价值或底层代码库的可维护性。

This simple example shows how easy it is to game the coverage numbers. The more compact your code is, the better the test coverage metric becomes, because it only accounts for the raw line numbers. At the same time, squashing more code into less space doesn’t (and shouldn’t) change the value of the test suite or the maintainability of the underlying code base.

1.3.2 了解分支覆盖率指标

1.3.2  Understanding the branch coverage metric

另一个覆盖率指标称为分支覆盖率。分支覆盖比代码覆盖提供更精确的结果,因为它有助于应对代码覆盖的缺点。该指标没有使用代码行的原始数量,而是侧重于控制结构,例如ifswitch语句。它显示了套件中至少有一个测试遍历了多少这样的控制结构,如图 1.4 所示。

Another coverage metric is called branch coverage. Branch coverage provides more precise results than code coverage because it helps cope with code coverage’s shortcomings. Instead of using the raw number of code lines, this metric focuses on control structures, such as if and switch statements. It shows how many of such control structures are traversed by at least one test in the suite, as shown in figure 1.4.

公式 1.2:分支指标计算为测试套件执行的代码分支数与生产代码库中分支总数的比率。

Equation 1.2: The branch metric is calculated as the ratio of the number of code branches exercised by the test suite and the total number of branches in the production code base.

分支覆盖率=遍历的分支/分支总数

Branch coverage = Branches traversed / Total number of branches

要计算分支覆盖率指标,您需要汇总代码库中所有可能的分支,并查看测试访问了其中的多少分支。让我们再次以我们之前的例子为例:

To calculate the branch coverage metric, you need to sum up all possible branches in your code base and see how many of them are visited by tests. Let’s take our previous example again:

public static bool IsStringLong(字符串输入)
{
    返回输入。长度 > 5;
}

公共无效测试()
{
    布尔结果 = IsStringLong("abc");
    断言。等于(假,结果);
}
public static bool IsStringLong(string input)
{
    return input.Length > 5;
}

public void Test()
{
    bool result = IsStringLong("abc");
    Assert.Equal(false, result);
}

该方法有两个分支IsStringLong:一个用于字符串参数的长度大于五个字符的情况,另一个用于不大于五个字符的情况。测试仅覆盖其中一个分支,因此分支覆盖率指标为 1/2 = 0.5 = 50%。我们如何表示被测代码并不重要——我们是if像以前一样使用语句还是使用更短的表示法。分支覆盖率指标仅考虑分支数量;它没有考虑实现这些分支需要多少行代码。

There are two branches in the IsStringLong method: one for the situation when the length of the string argument is greater than five characters, and the other one when it’s not. The test covers only one of these branches, so the branch coverage metric is 1/2 = 0.5 = 50%. And it doesn’t matter how we represent the code under test — whether we use an if statement as before or use the shorter notation. The branch coverage metric only accounts for the number of branches; it doesn’t take into consideration how many lines of code it took to implement those branches.

1.3显示了一种可视化此指标的有用方法。您可以将被测代码可以采用的所有可能路径表示为图形,并查看其中有多少已被遍历。IsStringLong有两条这样的路径,测试只练习其中一条。

Figure 1.3 shows a helpful way to visualize this metric. You can represent all possible paths the code under test can take as a graph and see how many of them have been traversed. IsStringLong has two such paths, and the test exercises only one of them.

图 1.3。该方法IsStringLong表示为可能的代码路径图。Test仅覆盖两个代码路径之一,从而提供 50% 的分支覆盖率。

Figure 1.3. The method IsStringLong represented as a graph of possible code paths. Test covers only one of the two code paths, thus providing 50% branch coverage.

CH01 图5

1.3.3 覆盖指标的问题

1.3.3  Problems with coverage metrics

虽然分支覆盖率指标比代码覆盖率产生更好的结果,但您仍然不能依赖它们中的任何一个来确定测试套件的质量,原因有两个:

Although the branch coverage metric yields better results than code coverage, you still can’t rely on either of them to determine the quality of your test suite, for two reasons:

  • 您不能保证测试会验证被测系统的所有可能结果。

  • You can’t guarantee that the test verifies all the possible outcomes of the system under test.

  • 没有覆盖率指标可以考虑外部库中的代码路径。

  • No coverage metric can take into account code paths in external libraries.

让我们更仔细地看看这些原因中的每一个。

Let’s look more closely at each of these reasons.

您不能保证测试会验证所有可能的结果

You can’t guarantee that the test verifies all the possible outcomes

对于要实际测试而不仅仅是练习的代码路径,您的单元测试必须具有适当的断言。换句话说,您需要检查被测系统产生的结果是否正是您期望它产生的结果。此外,这一结果可能有几个组成部分;为了使覆盖率指标有意义,您需要验证所有这些指标。

For the code paths to be actually tested and not just exercised, your unit tests must have appropriate assertions. In other words, you need to check that the outcome the system under test produces is the exact outcome you expect it to produce. Moreover, this outcome may have several components; and for the coverage metrics to be meaningful, you need to verify all of them.

下一个清单显示了该IsStringLong方法的另一个版本。它将最后的结果记录到公共WasLastStringLong财产中。

The next listing shows another version of the IsStringLong method. It records the last result into a public WasLastStringLong property.

清单 1.2。IsStringLong记录最后结果的那个版本

Listing 1.2. Version of IsStringLong that records the last result

公共静态布尔 WasLastStringLong { 得到; 私有集;}

public static bool IsStringLong(字符串输入)
{
    bool result = input.Length > 5;
    WasLastStringLong = 结果;    
    返回结果;                 
}

公共无效测试()
{
    布尔结果 = IsStringLong("abc");
    断言。等于(假,结果);    
}
public static bool WasLastStringLong { get; private set; }

public static bool IsStringLong(string input)
{
    bool result = input.Length > 5;
    WasLastStringLong = result;    
    return result;                 
}

public void Test()
{
    bool result = IsStringLong("abc");
    Assert.Equal(false, result);   
}

第一个结果

First outcome

第二个结果

Second outcome

该测试仅验证第二个结果。

The test verifies only the second outcome.

IsStringLong方法现在有两个结果:一个显式结果,由返回值编码;和一个隐含的,这是该属性的新值。尽管没有验证第二个隐式结果,但覆盖率指标仍会显示相同的结果:代码覆盖率为 100%,分支覆盖率为 50%。如您所见,覆盖率指标并不能保证底层代码已被测试,只能保证它已在某个时刻执行过。

The IsStringLong method now has two outcomes: an explicit one, which is encoded by the return value; and an implicit one, which is the new value of the property. And in spite of not verifying the second, implicit outcome, the coverage metrics would still show the same results: 100% for the code coverage and 50% for the branch coverage. As you can see, the coverage metrics don’t guarantee that the underlying code is tested, only that it has been executed at some point.

这种具有部分测试结果的情况的极端版本​​是无断言测试,即您编写的测试中没有任何断言语句。这是无断言测试的示例。

An extreme version of this situation with partially tested outcomes is assertion-free testing, which is when you write tests that don’t have any assertion statements in them whatsoever. Here’s an example of assertion-free testing.

清单 1.3。没有断言的测试总是通过。

Listing 1.3. A test with no assertions always passes.

公共无效测试()
{
    bool result1 = IsStringLong("abc");       
    bool result2 = IsStringLong("abcdef");    
}
public void Test()
{
    bool result1 = IsStringLong("abc");      
    bool result2 = IsStringLong("abcdef");   
}

返回真

Returns true

返回假

Returns false

此测试的代码覆盖率和分支覆盖率指标均显示 100%。但与此同时,它完全没有用,因为它不验证任何东西。

This test has both code- and branch-coverage metrics showing 100%. But at the same time, it is completely useless because it doesn’t verify anything.

战壕里的故事

A story from the trenches

无断言测试的概念可能看起来像一个愚蠢的想法,但它确实发生在野外。

The concept of assertion-free testing might look like a dumb idea, but it does happen in the wild.

多年前,我在一个项目中工作,管理层对每个正在开发的项目提出了 100% 代码覆盖率的严格要求。这一倡议具有崇高的意图。那是在单元测试不像今天这样普遍的时候。组织中很少有人实践它,而始终如一地进行单元测试的人就更少了。

Years ago, I worked on a project where management imposed a strict requirement of having 100% code coverage for every project under development. This initiative had noble intentions. It was during the time when unit testing wasn’t as prevalent as it is today. Few people in the organization practiced it, and even fewer did unit testing consistently.

一群开发人员参加了一个会议,会议上有很多讨论都专门讨论单元测试。回来后,他们决定将新学到的知识付诸实践。高层管理人员支持他们,开始向更好的编程技术转变。进行了内部演示。安装了新工具。而且,更重要的是,在公司范围内实施了一项新规则:所有开发团队都必须专注于编写测试,直到他们达到 100% 的代码覆盖率标记。在他们达到这个目标后,任何降低指标的代码签入都必须被构建系统拒绝。

A group of developers had gone to a conference where many talks were devoted to unit testing. After returning, they decided to put their new knowledge into practice. Upper management supported them, and the great conversion to better programming techniques began. Internal presentations were given. New tools were installed. And, more importantly, a new company-wide rule was imposed: all development teams had to focus on writing tests exclusively until they reached the 100% code coverage mark. After they reached this goal, any code check-in that lowered the metric had to be rejected by the build systems.

正如您可能猜到的那样,结果并不理想。受到这种严重限制的打击,开发人员开始寻找玩弄该系统的方法。自然地,他们中的许多人得出了相同的认识:如果您用try/catch块包装所有测试并且不在其中引入任何断言,那么这些测试一定会通过。为了满足强制性的 100% 覆盖率要求,人们开始盲目地创建测试。不用说,那些测试并没有给项目增加任何价值。此外,由于他们从生产活动中转移了所有的精力和时间,并且因为维持测试向前推进所需的维护成本,他们破坏了项目。

As you might guess, this didn’t play out well. Crushed by this severe limitation, developers started to seek ways to game the system. Naturally, many of them came to the same realization: if you wrap all tests with try/catch blocks and don’t introduce any assertions in them, those tests are guaranteed to pass. People started to mindlessly create tests for the sake of meeting the mandatory 100% coverage requirement. Needless to say, those tests didn’t add any value to the projects. Moreover, they damaged the project because of all the effort and time they steered away from productive activities, and because of the upkeep costs required to maintain the tests moving forward.

最终,要求降低到 90%,然后降低到 80%,并且在一段时间后完全收回(为了更好!)。

Eventually, the requirement was lowered to 90% and then to 80%, and, after some period of time, retracted altogether (for the better!).

但是假设您彻底验证了被测代码的每个结果。这是否与分支覆盖率指标相结合,提供了一种可靠的机制,您可以使用它来确定测试套件的质量?很不幸的是,不行。

But let’s say that you thoroughly verify each outcome of the code under test. Does this, in combination with the branch-coverage metric, provide a reliable mechanism, which you can use to determine the quality of your test suite? Unfortunately, no.

没有覆盖率指标可以考虑外部库中的代码路径

No coverage metric can take into account code paths in external libraries

所有覆盖率指标的第二个问题是它们没有考虑外部库在被测系统调用它们的方法时经过的代码路径。让我们来看下面的例子:

The second problem with all coverage metrics is that they don’t take into account code paths that external libraries go through when the system under test calls methods on them. Let’s take the following example:

public static int Parse(字符串输入)
{
    返回 int.Parse(输入);
}

公共无效测试()
{
    int result = Parse("5");
    Assert.Equal(5, 结果);
}
public static int Parse(string input)
{
    return int.Parse(input);
}

public void Test()
{
    int result = Parse("5");
    Assert.Equal(5, result);
}

分支覆盖率指标显示 100%,测试验证了该方法结果的所有组件。无论如何,它只有一个这样的组件——返回值。与此同时,这个测试远非详尽无遗。它没有考虑 .NET Framework 的int.Parse方法可能经过的代码路径。并且有相当多的代码路径,即使是在这个简单的方法中,如图 1.6 所示。

The branch-coverage metric shows 100%, and the test verifies all components of the method’s outcome. It has a single such component anyway — the return value. At the same time, this test is nowhere near being exhaustive. It doesn’t take into account the code paths the .NET Framework’s int.Parse method may go through. And there are quite a number of code paths, even in this simple method, as you can see in figure 1.6.

图 1.4。外部库的隐藏代码路径。覆盖率指标无法查看其中有多少以及您的测试执行了多少。

Figure 1.4. Hidden code paths of external libraries. Coverage metrics have no way to see how many of them there are and how many of them your tests exercise.

CH01 图6

内置integer类型有很多分支,这些分支在测试中是隐藏的,如果您更改方法的输入参数,它们可能会导致不同的结果。以下是一些无法转换为整数的可能参数:

The built-in integer type has plenty of branches that are hidden from the test and that might lead to different results, should you change the method’s input parameter. Here are just a few possible arguments that can’t be transformed into an integer:

  • 空值

  • Null value

  • 一个空字符串

  • An empty string

  • “不是整数”

  • "Not an int"

  • 一个太大的字符串

  • A string that’s too large

您可能会陷入许多边缘情况,并且无法查看您的测试是否考虑了所有这些情况。

You can fall into numerous edge cases, and there’s no way to see if your tests account for all of them.

这并不是说覆盖率指标应该考虑外部库中的代码路径(它们不应该),而是向您表明您不能依赖这些指标来查看单元测试的好坏。覆盖率指标无法判断您的测试是否详尽无遗;他们也不能说您是否有足够的测试。

This is not to say that coverage metrics should take into account code paths in external libraries (they shouldn’t), but rather to show you that you can’t rely on those metrics to see how good or bad your unit tests are. Coverage metrics can’t possibly tell whether your tests are exhaustive; nor can they say if you have enough tests.

1.3.4 针对特定覆盖数

1.3.4  Aiming at a particular coverage number

在这一点上,我希望您能看到依靠覆盖率指标来确定测试套件的质量是不够的。如果您开始将特定的覆盖率数字作为目标,无论是 100%、90% 还是适度的 70%,它也可能导致危险区域。查看覆盖率指标的最佳方式是将其作为指标,而不是目标本身。

At this point, I hope you can see that relying on coverage metrics to determine the quality of your test suite is not enough. It can also lead to dangerous territory if you start making a specific coverage number a target, be it 100%, 90%, or even a moderate 70%. The best way to view a coverage metric is as an indicator, not a goal in and of itself.

想想医院里的病人。他们的高温可能表明发烧,这是一个有用的观察。但是医院不应该以任何必要的方式将这名患者的适当体温作为目标。否则,医院最终可能会采取快速“有效”的解决方案,即在患者旁边安装空调,并通过调节流到患者皮肤上的冷空气量来调节体温。当然,这种做法没有任何意义。

Think of a patient in a hospital. Their high temperature might indicate a fever and is a helpful observation. But the hospital shouldn’t make the proper temperature of this patient a goal to target by any means necessary. Otherwise, the hospital might end up with the quick and "efficient" solution of installing an air conditioner next to the patient and regulating their temperature by adjusting the amount of cold air flowing onto their skin. Of course, this approach doesn’t make any sense.

同样,以特定的覆盖率数字为目标会产生违背单元测试目标的不当激励。人们开始寻找实现这个人为目标的方法,而不是专注于测试重要的事情。正确的单元测试已经够困难了。强加一个强制性的覆盖率数字只会分散开发人员对他们测试内容的注意力,并使正确的单元测试更难实现。

Likewise, targeting a specific coverage number creates a perverse incentive that goes against the goal of unit testing. Instead of focusing on testing the things that matter, people start to seek ways to attain this artificial target. Proper unit testing is difficult enough already. Imposing a mandatory coverage number only distracts developers from being mindful about what they test, and makes proper unit testing even harder to achieve.

 

 

[提示] 提示

对系统的核心部分进行高水平覆盖是件好事。将这种高水平作为要求是很糟糕的。差异很微妙,但很关键。

It’s good to have a high level of coverage in core parts of your system. It’s bad to make this high level a requirement. The difference is subtle but critical.

让我重复一遍:覆盖率指标是一个很好的负面指标,但却是一个糟糕的正面指标。低覆盖率数字——比如,低于 60%——是麻烦的某种迹象。它们意味着您的代码库中有很多未经测试的代码。但高数字并不意味着什么。因此,测量代码覆盖率应该只是通往高质量测试套件的第一步。

Let me repeat myself: coverage metrics are a good negative indicator, but a bad positive one. Low coverage numbers — say, below 60% — are a certain sign of trouble. They mean there’s a lot of untested code in your code base. But high numbers don’t mean anything. Thus, measuring the code coverage should be only a first step on the way to a quality test suite.

1.4 什么是成功的测试套件?

1.4  What makes a successful test suite?

本章的大部分时间我都在讨论衡量测试套件质量的不正确方法:使用覆盖率指标。正确的方法呢?您应该如何衡量测试套件的质量?唯一可靠的方法是逐一评估套件中的每个测试。当然,您不必一次评估所有这些;这可能是一项艰巨的任务,需要大量的前期工作。您可以逐步执行此评估。关键是没有自动的方法来查看您的测试套件有多好。你必须运用你的个人判断。

I’ve spent most of this chapter discussing improper ways to measure the quality of a test suite: using coverage metrics. What about a proper way? How should you measure your test suite’s quality? The only reliable way is to evaluate each test in the suite individually, one by one. Of course, you don’t have to evaluate all of them at once; that could be quite a large undertaking and require significant upfront effort. You can perform this evaluation gradually. The point is that there’s no automated way to see how good your test suite is. You have to apply your personal judgment.

让我们从更广泛的角度来看一下是什么让测试套件作为一个整体取得成功。(我们将在第 4 章深入探讨区分好测试和坏测试的细节。)成功的测试套件具有以下属性:

Let’s look at a broader picture of what makes a test suite successful as a whole. (We’ll dive into the specifics of differentiating between good and bad tests in chapter 4.) A successful test suite has the following properties:

  • 它已集成到开发周期中。

  • It’s integrated into the development cycle.

  • 它只针对代码库中最重要的部分。

  • It targets only the most important parts of your code base.

  • 它以最低的维护成本提供最大的价值。

  • It provides maximum value with minimum maintenance costs.

1.4.1 融入开发周期

1.4.1  It’s integrated into the development cycle

进行自动化测试的唯一要点是您是否经常使用它们。所有测试都应集成到开发周期中。理想情况下,您应该在每次代码更改时执行它们,即使是最小的代码更改。

The only point in having automated tests is if you constantly use them. All tests should be integrated into the development cycle. Ideally, you should execute them on every code change, even the smallest one.

1.4.2 它只针对代码库中最重要的部分

1.4.2  It targets only the most important parts of your code base

正如并非所有测试都一样,就单元测试而言,并非代码库的所有部分都值得同等关注。测试提供的价值不仅在于这些测试本身的结构,还在于它们验证的代码。

Just as all tests are not created equal, not all parts of your code base are worth the same attention in terms of unit testing. The value the tests provide is not only in how those tests themselves are structured, but also in the code they verify.

重要的是将您的单元测试工作指向系统的最关键部分,而仅简要或间接地验证其他部分。在大多数应用程序中,最重要的部分是包含业务逻辑的部分——领域模型[1]测试业务逻辑可为您的时间投资带来最佳回报。

It’s important to direct your unit testing efforts to the most critical parts of the system and verify the others only briefly or indirectly. In most applications, the most important part is the part that contains business logic — the domain model.[1] Testing business logic gives you the best return on your time investment.

所有其他部分可以分为三类:

All other parts can be divided into three categories:

  • 基础设施代码

  • Infrastructure code

  • 外部服务和依赖,例如数据库和第三方系统

  • External services and dependencies, such as the database and third-party systems

  • 将所有内容粘合在一起的代码

  • Code that glues everything together

不过,其中一些其他部分可能仍需要彻底的单元测试。例如,基础设施代码可能包含复杂而重要的算法,因此用大量测试覆盖它们也是有意义的。但一般来说,你的大部分注意力应该花在领域模型上。

Some of these other parts may still need thorough unit testing, though. For example, the infrastructure code may contain complex and important algorithms, so it would make sense to cover them with a lot of tests, too. But in general, most of your attention should be spent on the domain model.

您的一些测试(例如集成测试)可以超越域模型并验证系统作为一个整体的工作方式,包括代码库的非关键部分。这很好。但重点应该放在领域模型上。

Some of your tests, such as integration tests, can go beyond the domain model and verify how the system works as a whole, including the noncritical parts of the code base. And that’s fine. But the focus should remain on the domain model.

请注意,为了遵循此准则,您应该将领域模型与代码库的非必要部分隔离开来。您必须将域模型与所有其他应用程序问题分开,以便您可以将单元测试工作专门集中在该域模型上。我们将在本书的第 2 部分详细讨论所有这些内容。

Note that in order to follow this guideline, you should isolate the domain model from the non-essential parts of the code base. You have to keep the domain model separated from all other application concerns so you can focus your unit testing efforts on that domain model exclusively. We talk about all this in detail in part 2 of the book.

1.4.3 以最低维护成本提供最大价值

1.4.3  It provides maximum value with minimum maintenance costs

单元测试最困难的部分是以最小的维护成本实现最大价值。这是本书的主要关注点。

The most difficult part of unit testing is achieving maximum value with minimum maintenance costs. That’s the main focus of this book.

将测试合并到构建系统中是不够的,并且维持域模型的高测试覆盖率也是不够的。仅在套件中保留价值大大超过维护成本的测试也很重要。

It’s not enough to incorporate tests into a build system, and it’s not enough to maintain high test coverage of the domain model. It’s also crucial to keep in the suite only the tests whose value exceeds their upkeep costs by a good margin.

最后一个属性可以分为两个:

This last attribute can be divided in two:

  • 识别有价值的测试(并且,推而广之,低价值的测试)

  • Recognizing a valuable test (and, by extension, a test of low value)

  • 编写有价值的测试

  • Writing a valuable test

尽管这些技能看起来很相似,但它们本质上是不同的。要识别高价值的测试,您需要一个参考框架。另一方面,编写有价值的测试还需要您了解代码设计技术。单元测试和底层代码高度交织在一起,如果不对它们所涵盖的代码库投入大量精力,就不可能创建有价值的测试。

Although these skills may seem similar, they’re different by nature. To recognize a test of high value, you need a frame of reference. On the other hand, writing a valuable test requires you to also know code design techniques. Unit tests and the underlying code are highly intertwined, and it’s impossible to create valuable tests without putting significant effort into the code base they cover.

您可以将其视为识别一首好歌曲和能够创作一首好歌曲之间的区别。成为作曲家所需要付出的努力,要比区分音乐好坏所需要付出的努力多得不对称。单元测试也是如此。编写新测试比检查现有测试需要更多的努力,主要是因为您不是在真空中编写测试:您必须考虑底层代码。因此,虽然我专注于单元测试,但我也将本书的重要部分用于讨论代码设计。

You can view it as the difference between recognizing a good song and being able to compose one. The amount of effort required to become a composer is asymmetrically larger than the effort required to differentiate between good and bad music. The same is true for unit tests. Writing a new test requires more effort than examining an existing one, mostly because you don’t write tests in a vacuum: you have to take into account the underlying code. And so although I focus on unit tests, I also devote a significant portion of this book to discussing code design.



[1]请参阅Eric Evans 的领域驱动设计:解决软件核心的复杂性问题(Addison-Wesley,2003 年)。

[1] See Domain-Driven Design: Tackling Complexity in the Heart of Software by Eric Evans (Addison-Wesley, 2003).

1.5 本书内容

1.5  What you will learn in this book

本书介绍了一个参考框架,您可以使用它来分析测试套件中的任何测试。这个参考框架是基础的。学习它之后,您将能够以新的眼光看待您的许多测试,并了解它们中的哪些对项目有贡献,哪些必须重构或完全删除。

This book teaches a frame of reference that you can use to analyze any test in your test suite. This frame of reference is foundational. After learning it, you’ll be able to look at many of your tests in a new light and see which of them contribute to the project and which must be refactored or gotten rid of altogether.

设置好这个阶段(第4章)后,本书分析了现有的单元测试技术和实践(第4-6章,以及第7章的一部分)。您是否熟悉这些技术和实践并不重要。如果您熟悉它们,就会从一个新的角度看待它们。您很可能已经在直观的层面上了解了它们。这本书可以帮助您阐明为什么您一直使用的技术和最佳实践如此有用。

After setting this stage (chapter 4), the book analyzes the existing unit testing techniques and practices (chapters 4-6, and part of 7). It doesn’t matter whether you’re familiar with those techniques and practices. If you are familiar with them, you’ll see them from a new angle. Most likely, you already get them at the intuitive level. This book can help you articulate why the techniques and best practices you’ve been using all along are so helpful.

不要小看这个技能。向同事清楚地传达您的想法的能力是无价的。一个软件开发人员——即使是一个伟大的开发人员——如果不能准确地解释为什么做出了这个决定,那么他们很少会因为一个设计决定而得到充分的信任。这本书可以帮助你将你的知识从无意识的领域转变为你可以与任何人谈论的东西。

Don’t underestimate this skill. The ability to clearly communicate your ideas to colleagues is priceless. A software developer — even a great one — rarely gets full credit for a design decision if they can’t explain why, exactly, that decision was made. This book can help you transform your knowledge from the realm of the unconscious to something you are able to talk about with anyone.

如果您在单元测试技术和最佳实践方面没有太多经验,您会学到很多东西。除了可以用来分析测试套件中的任何测试的参考框架之外,本书还教授

If you don’t have much experience with unit testing techniques and best practices, you’ll learn a lot. In addition to the frame of reference that you can use to analyze any test in a test suite, the book teaches

  • 如何重构测试套件及其涵盖的生产代码

  • How to refactor the test suite along with the production code it covers

  • 如何应用不同风格的单元测试

  • How to apply different styles of unit testing

  • 使用集成测试来验证整个系统的行为

  • Using integration tests to verify the behavior of the system as a whole

  • 识别和避免单元测试中的反模式

  • Identifying and avoiding anti-patterns in unit tests

除了单元测试之外,本书涵盖了自动化测试的整个主题,因此您还将了解集成和端到端测试。

In addition to unit tests, this book covers the entire topic of automated testing, so you’ll also learn about integration and end-to-end tests.

我在我的代码示例中使用了 C# 和 .NET,但你不必成为 C# 专业人士也能阅读本书;C# 恰好是我使用最多的语言。我谈论的所有概念都是非语言特定的,可以应用于任何其他面向对象的语言,例如 Java 或 C++。

I use C# and .NET in my code samples, but you don’t have to be a C# professional to read this book; C# is just the language that I happen to work with the most. All the concepts I talk about are non-language-specific and can be applied to any other object-oriented language, such as Java or C++.

1.6 总结

1.6  Summary

  • 代码趋于恶化。每次您更改代码库中的某些内容时,其中的混乱程度或熵都会增加。如果没有适当的照顾,例如不断的清理和重构,系统就会变得越来越复杂和混乱。测试有助于推翻这种趋势。它们充当安全网——一种为绝大多数回归提供保险的工具。

  • Code tends to deteriorate. Each time you change something in a code base, the amount of disorder in it, or entropy, increases. Without proper care, such as constant cleaning and refactoring, the system becomes increasingly complex and disorganized. Tests help overturn this tendency. They act as a safety net —  a tool that provides insurance against the vast majority of regressions.

  • 编写单元测试很重要。编写好的单元测试同样重要。测试不佳或没有测试的项目的最终结果是相同的:每个新版本要么停滞不前,要么出现大量倒退。

  • It’s important to write unit tests. It’s equally important to write good unit tests. The end result for projects with bad tests or no tests is the same: either stagnation or a lot of regressions with every new release.

  • 单元测试的目标是使软件项目可持续发展。一个好的单元测试套件有助于避免停滞阶段并随着时间的推移保持开发速度。有了这样的套件,您就可以确信您的更改不会导致性能下降。反过来,这使得重构代码或添加新功能变得更加容易。

  • The goal of unit testing is to enable sustainable growth of the software project. A good unit test suite helps avoid the stagnation phase and maintain the development pace over time. With such a suite, you’re confident that your changes won’t lead to regressions. This, in turn, makes it easier to refactor the code or add new features.

  • 并非所有测试都是平等的。每项测试都有成本和收益组成部分,您需要仔细权衡其中一项。只在套件中保留正净值的测试,并摆脱所有其他测试。应用代码和测试代码都是负债,不是资产。

  • All tests are not created equal. Each test has a cost and a benefit component, and you need to carefully weigh one against the other. Keep only tests of positive net value in the suite, and get rid of all others. Both the application code and the test code are liabilities, not assets.

  • 单元测试代码的能力是一个很好的试金石,但它只在一个方向上起作用。这是一个很好的负面指标(如果你不能对代码进行单元测试,那么它的质量很差)但是一个不好的正面指标(对代码进行单元测试的能力并不能保证它的质量)。

  • The ability to unit test code is a good litmus test, but it only works in one direction. It’s a good negative indicator (if you can’t unit test the code, it’s of poor quality) but a bad positive one (the ability to unit test the code doesn’t guarantee its quality).

  • 同样,覆盖率指标是一个很好的负面指标,但不是一个积极的指标。低覆盖率数字是麻烦的某种迹象,但高覆盖率数字并不自动意味着您的测试套件是高质量的。

  • Likewise, coverage metrics are a good negative indicator but a bad positive one. Low coverage numbers are a certain sign of trouble, but a high coverage number doesn’t automatically mean your test suite is of high quality.

  • 分支覆盖率可以更好地了解测试套件的完整性,但仍然不能表明套件是否足够好。它不考虑断言的存在,也不考虑您的代码库使用的第三方库中的代码路径。

  • Branch coverage provides better insight into the completeness of the test suite but still can’t indicate whether the suite is good enough. It doesn’t take into account the presence of assertions, and it can’t account for code paths in third-party libraries that your code base uses.

  • 强加一个特定的覆盖范围数字会产生不正当的激励。对系统的核心部分进行高水平的覆盖是件好事,但将这种高水平作为要求是不好的。

  • Imposing a particular coverage number creates a perverse incentive. It’s good to have a high level of coverage in core parts of your system, but it’s bad to make this high level a requirement.

  • 一个成功的测试套件具有以下属性:

    • 它被集成到开发周期中。

    • 它只针对代码库中最重要的部分。

    • 它以最低的维护成本提供最大的价值。

  • A successful test suite exhibits the following attributes:

    • It is integrated into the development cycle.

    • It targets only the most important parts of your code base.

    • It provides maximum value with minimum maintenance costs.

  • 实现单元测试目标(即实现可持续的项目增长)的唯一方法是

    • 学习如何区分好的和坏的测试。

    • 能够重构测试以使其更有价值。

  • The only way to achieve the goal of unit testing (that is, enabling sustainable project growth) is to

    • Learn how to differentiate between a good and a bad test.

    • Be able to refactor a test to make it more valuable.

2

2

什么是单元测试?

What is a unit test?

本章涵盖:

This chapter covers:

  • 什么是单元测试

  • What a unit test is

  • 共享依赖、私有依赖和可变依赖的区别

  • The differences between shared, private, and volatile dependencies

  • 单元测试的两个流派:经典和伦敦

  • The two schools of unit testing: classical and London

  • 单元测试、集成测试和端到端测试之间的区别

  • The differences between unit, integration, and end-to-end tests

如第 1 章所述,单元测试的定义中存在数量惊人的细微差别。这些细微差别比您想象的更重要——以至于在解释它们时的差异导致了关于如何进行单元测试的两种截然不同的观点。

As mentioned in chapter 1, there are a surprising number of nuances in the definition of a unit test. Those nuances are more important than you might think — so much so that the differences in interpreting them have led to two distinct views on how to approach unit testing.

这些观点被称为单元测试的经典伦敦学派。经典学派之所以被称为“经典”,是因为它是每个人最初接触单元测试和测试驱动开发的方式。伦敦学派扎根于伦敦的编程社区。本章中关于古典风格和伦敦风格之间差异的讨论为第 5 章奠定了基础,我将在第 5 章详细介绍模拟和测试脆弱性的主题。

These views are known as the classical and the London schools of unit testing. The classical school is called "classical" because it’s how everyone originally approached unit testing and test-driven development. The London school takes root in the programming community in London. The discussion in this chapter about the differences between the classical and London styles lays the foundation for chapter 5, where I cover the topic of mocks and test fragility in detail.

在本章中,我将从经典学派和伦敦学派的角度比较单元测试和集成测试。让我们从定义单元测试开始,包括所有应有的注意事项和微妙之处。这个定义是区分古典学派和伦敦学派的关键。

In this chapter, I compare unit and integration testing from the perspectives of the classical and London schools. Let’s start by defining a unit test, with all due caveats and subtleties. This definition is the key to the difference between the classical and London schools.

2.1 “单元测试”的定义

2.1  The definition of "unit test"

单元测试有很多定义。除去非必要的部分,这些定义都具有以下三个最重要的属性。单元测试是一种自动化测试,它

There are a lot of definitions of a unit test. Stripped of their non-essential bits, the definitions all have the following three most important attributes. A unit test is an automated test that

  • 验证一小段代码(也称为 unit

  • Verifies a small piece of code (also known as a unit),

  • 做得很快,

  • Does it quickly,

  • 并以孤立的方式。

  • And in an isolated manner.

这里的前两个属性没有争议。关于什么是快速单元测试,可能存在一些争议,因为这是一个非常主观的衡量标准。但总的来说,它并不那么重要。如果您的测试套件的执行时间对您来说足够好,则意味着您的测试足够快。

The first two attributes here are pretty non-controversial. There might be some dispute as to what exactly constitutes a fast unit test because, well, it’s a highly subjective measure. But overall, it’s not that important. If your test suite’s execution time is good enough for you, it means your tests are quick enough.

人们对第三个属性有着截然不同的看法。隔离问题是经典单元测试和伦敦单元测试学派之间差异的根源。在本节中,我将描述孤立问题的全部内容,并从伦敦和古典学派的角度对其进行探讨。正如您将在下一节中看到的那样,这两个学派之间的所有其他差异都自然地源于对隔离的确切含义的这一单一分歧。由于我在 2.3 节中描述的原因,我更喜欢古典风格。

What people have vastly different opinions about is the third attribute. The isolation issue is the root of the differences between the classical and London schools of unit testing. In this section, I describe what that isolation issue is all about and explore it from the London and then the classical school’s perspectives. As you will see in the next section, all other differences between the two schools flow naturally from this single disagreement on what exactly isolation means. I prefer the classical style for the reasons I describe in section 2.3.

经典的和伦敦的单元测试学派

The classical and London schools of unit testing

经典方法也称为底特律,有时也称为单元测试的经典方法。关于经典学派最经典的书可能是 Kent Beck 的那本书:Test-Driven Development: By Example(Addison-Wesley Professional,2002 年)。

The classical approach is also referred to as the Detroit and, sometimes, the classicist approach to unit testing. Probably the most canonical book on the classical school is the one by Kent Beck: Test-Driven Development: By Example (Addison-Wesley Professional, 2002).

伦敦风格有时被称为模仿者尽管mockist一词很流行,但坚持这种单元测试风格的人通常不喜欢它,因此我在本书中将其称为伦敦风格。这种方法最著名的支持者是 Steve Freeman 和 Nat Pryce。我推荐他们的书Growing Object-Oriented Software, Guided by Tests(Addison-Wesley Professional,2009 年)作为该主题的良好来源。

The London style is sometimes referred to as mockist. Although the term mockist is widespread, people who adhere to this style of unit testing generally don’t like it, so I call it the London style throughout this book. The most prominent proponents of this approach are Steve Freeman and Nat Pryce. I recommend their book Growing Object-Oriented Software, Guided by Tests (Addison-Wesley Professional, 2009) as a good source on this subject.

2.1.1 隔离问题:伦敦采取

2.1.1  The isolation issue: The London take

以隔离方式验证一段代码(一个单元)意味着什么?伦敦学校将其描述为将被测系统与其合作者隔离开来。这意味着如果一个类依赖于另一个类或多个类,则需要将所有此类依赖项替换为测试替身。这样,您可以通过将其行为与任何外部影响分开来专门关注被测类。

What does it mean to verify a piece of code — a unit — in an isolated manner? The London school describes it as isolating the system under test from its collaborators. It means if a class has a dependency on another class, or several classes, you need to replace all such dependencies with test doubles. This way, you can focus on the class under test exclusively by separating its behavior from any external influence.

定义 2.1:

Definition 2.1:

测试替身是一个对象,其外观和行为与其预期发布的对象相似,但实际上是一个简化版本,可降低复杂性并促进测试。该术语由 Gerard Meszaros 在他的书xUnit 测试模式:重构测试代码(Addison-Wesley,2007 年)中引入。这个名字本身来自电影中替身的概念。

A test double is an object that looks and behaves like its release-intended counterpart but is actually a simplified version that reduces the complexity and facilitates testing. This term was introduced by Gerard Meszaros in his book xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007). The name itself comes from the notion of a stunt double in movies.

2.1显示了通常如何实现隔离。一个单元测试,否则将验证被测系统及其所有依赖项,现在可以独立于这些依赖项来执行此操作。

Figure 2.1 shows how the isolation is usually achieved. A unit test that would otherwise verify the system under test along with all its dependencies now can do that separately from those dependencies.

图 2.1。用测试替身替换被测系统的依赖关系可以让您专注于专门验证被测系统,以及拆分原本庞大的互连对象图。

Figure 2.1. Replacing the dependencies of the system under test with test doubles allows you to focus on verifying the system under test exclusively, as well as split the otherwise large interconnected object graph.

CH02 图1 隔离测试1

这种方法的一个好处是,如果测试失败,您可以确定代码库的哪一部分被破坏:它是被测系统。不可能有其他嫌疑人,因为班上所有的邻居都被替身代替了。

One benefit of this approach is that if the test fails, you know for sure which part of the code base is broken: it’s the system under test. There could be no other suspects, because all of the class’s neighbors are replaced with the test doubles.

另一个好处是能够拆分对象图 ——解决相同问题的通信类网络。这个网络可能会变得相当复杂:其中的每个类都可能有几个直接依赖项,每个依赖项都依赖于自己的依赖项,等等。类甚至可能引入循环依赖,依赖链最终会回到它开始的地方。

Another benefit is the ability to split the object graph — the web of communicating classes solving the same problem. This web may become quite complicated: every class in it may have several immediate dependencies, each of which relies on dependencies of their own, and so on. Classes may even introduce circular dependencies, where the chain of dependency eventually comes back to where it started.

如果没有测试替身,就很难测试这样一个相互关联的代码库。您剩下的唯一选择几乎是在测试中重新创建完整的对象图,如果其中的类数量太多,这可能不是一项可行的任务。

Trying to test such an interconnected code base is hard without test doubles. Pretty much the only choice you are left with is re-creating the full object graph in the test, which might not be a feasible task if the number of classes in it is too high.

有了测试替身,你可以阻止这种情况。您可以替换类的直接依赖项;而且,通过扩展,您不必处理这些依赖项的依赖项,等等递归路径。您实际上是在分解图表——这可以显着减少您在单元测试中必须做的准备工作。

With test doubles, you can put a stop to this. You can substitute the immediate dependencies of a class; and, by extension, you don’t have to deal with the dependencies of those dependencies, and so on down the recursion path. You are effectively breaking up the graph — and that can significantly reduce the amount of preparations you have to do in a unit test.

我们不要忘记这种单元测试隔离方法的另一个小而令人愉快的附带好处:它允许您引入一次只测试一个类的项目范围指南,这在整个单元测试套件中建立了一个简单的结构。您不再需要考虑如何用测试覆盖您的代码库。有课吗?用单元测试创​​建相应的类!图2.2显示了它通常的样子。

And let’s not forget another small but pleasant side benefit of this approach to unit test isolation: it allows you to introduce a project-wide guideline of testing only one class at a time, which establishes a simple structure in the whole unit test suite. You no longer have to think much about how to cover your code base with tests. Have a class? Create a corresponding class with unit tests! Figure 2.2 shows how it usually looks.

图 2.2。将被测类与其依赖项隔离有助于建立一个简单的测试套件结构:一个类对生产代码中的每个类进行测试。

Figure 2.2. Isolating the class under test from its dependencies helps establish a simple test suite structure: one class with tests for each class in the production code.

CH02 图2隔离测试1结构

现在让我们看一些例子。由于大多数人可能看起来更熟悉经典风格,因此我将首先展示以该风格编写的示例测试,然后使用伦敦方法重写它们。

Let’s now look at some examples. Since the classical style probably looks more familiar to most people, I’ll show sample tests written in that style first and then rewrite them using the London approach.

假设我们经营一家在线商店。在我们的示例应用程序中只有一个简单的用例:客户可以购买产品。当店内库存充足时,即视为购买成功,店内商品数量减去购买金额。如果没有足够的产品,则购买不成功,并且商店中没有任何反应。

Let’s say that we operate an online store. There’s just one simple use case in our sample application: a customer can purchase a product. When there’s enough inventory in the store, the purchase is deemed to be successful, and the amount of the product in the store is reduced by the purchase’s amount. If there’s not enough product, the purchase is not successful, and nothing happens in the store.

以下清单显示了两个测试,验证仅当商店中有足够的库存时购买才会成功。测试以经典风格编写,并使用典型的三阶段顺序:安排、行动和断言(简称 AAA——我在第 3 章中详细讨论了这个顺序)。

The following listing shows two tests verifying that a purchase succeeds only when there’s enough inventory in the store. The tests are written in the classical style and use the typical three-phase sequence: arrange, act, and assert (AAA for short — I talk more about this sequence in chapter 3).

清单 2.1。使用经典的单元测试风格编写的测试

Listing 2.1. Tests written using the classical style of unit testing

[事实]
public void Purchase_succeeds_when_enough_inventory()
{
    // 安排
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // 行为
    bool success = customer.Purchase(store, Product.Shampoo, 5);

    //断言
    断言。真(成功);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));   
}

[事实]
public void Purchase_fails_when_not_enough_inventory()
{
    // 安排
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // 行为
    bool success = customer.Purchase(store, Product.Shampoo, 15);

    //断言
    断言.False(成功);
    Assert.Equal(10, store.GetInventory(Product.Shampoo));   
}

公共枚举产品
{
    洗发水,
    书
}
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(store, Product.Shampoo, 5);

    // Assert
    Assert.True(success);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));   
}

[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(store, Product.Shampoo, 15);

    // Assert
    Assert.False(success);
    Assert.Equal(10, store.GetInventory(Product.Shampoo));   
}

public enum Product
{
    Shampoo,
    Book
}

将商店中的产品数量减少五

Reduces the product amount in the store by five

商店中的产品数量保持不变。

The product amount in the store remains unchanged.

如您所见,安排部分是测试准备好所有依赖项和被测系统的地方。的调用customer.Purchase()是动作阶段,在这里你可以执行你想要验证的行为。assert 语句是验证阶段,您可以在其中检查行为是否导致预期结果。

As you can see, the arrange part is where the tests make ready all dependencies and the system under test. The call to customer.Purchase() is the act phase, where you exercise the behavior you want to verify. The assert statements are the verification stage, where you check to see if the behavior led to the expected results.

在安排阶段,测试将两种对象放在一起:被测系统 (SUT) 和一位协作者。在这种情况下,Customer是 SUT 并且Store是协作者。我们需要合作者有两个原因:

During the arrange phase, the tests put together two kinds of objects: the system under test (SUT) and one collaborator. In this case, Customer is the SUT and Store is the collaborator. We need the collaborator for two reasons:

  • 让被测方法编译,因为customer.Purchase()需要一个Store实例作为参数

  • To get the method under test to compile, because customer.Purchase() requires a Store instance as an argument

  • 对于断言阶段,由于其中一个结果customer.Purchase()是商店中的产品数量可能减少

  • For the assertion phase, since one of the results of customer.Purchase() is a potential decrease in the product amount in the store

Product.Shampoo和数字515是常量。

Product.Shampoo and the numbers 5 and 15 are constants.

定义 2.2:

Definition 2.2:

被测方法( MUT )是 SUT 中被测试调用的方法。术语MUTSUT通常用作同义词,但通常情况下,MUT指的是一种方法,而SUT指的是整个类。

A method under test (MUT) is a method in the SUT called by the test. The terms MUT and SUT are often used as synonyms, but normally, MUT refers to a method while SUT refers to the whole class.

此代码是经典单元测试样式的示例:测试不会替换协作者(类Store),而是使用它的生产就绪实例。这种风格的自然结果之一是测试现在有效地验证了两者,CustomerStore不仅仅是Customer. Store影响的内部工作中的任何错误Customer都会导致这些单元测试失败,即使Customer仍然可以正常工作。这两个类在测试中并不相互隔离。

This code is an example of the classical style of unit testing: the test doesn’t replace the collaborator (the Store class) but rather uses a production-ready instance of it. One of the natural outcomes of this style is that the test now effectively verifies both Customer and Store, not just Customer. Any bug in the inner workings of Store that affects Customer will lead to failing these unit tests, even if Customer still works correctly. The two classes are not isolated from each other in the tests.

现在让我们将示例修改为伦敦风格。我将进行相同的测试并Store用测试替身替换实例——具体来说,模拟。

Let’s now modify the example toward the London style. I’ll take the same tests and replace the Store instances with test doubles — specifically, mocks.

我使用 Moq ( https://github.com/moq/moq4 ) 作为模拟框架,但您可以找到几个同样好的替代方案,例如 NSubstitute ( https://github.com/nsubstitute/NSubstitute )。所有面向对象的语言都有类似的框架。例如,在 Java 世界中,您可以使用 Mockito、JMock 或 EasyMock。

I use Moq (https://github.com/moq/moq4) as the mocking framework, but you can find several equally good alternatives, such as NSubstitute (https://github.com/nsubstitute/NSubstitute). All object-oriented languages have analogous frameworks. For instance, in the Java world, you can use Mockito, JMock, or EasyMock.

定义 2.3:

Definition 2.3:

模拟是一种特殊的测试替身,允许您检查被测系统与其协作者之间的交互

A mock is a special kind of test double that allows you to examine interactions between the system under test and its collaborators.

我们将在后面的章节中回到模拟、存根以及它们之间的区别的主题。现在,要记住的主要事情是模拟是测试替身的一个子集。人们经常使用术语test doublemock作为同义词,但从技术上讲,它们不是(第 5 章对此有更多介绍):

We’ll get back to the topic of mocks, stubs, and the differences between them in later chapters. For now, the main thing to remember is that mocks are a subset of test doubles. People often use the terms test double and mock as synonyms, but technically, they are not (more on this in chapter 5):

  • 测试替身是一个包罗万象的术语,它描述了测试中各种非生产就绪的虚假依赖项。

  • Test double is an overarching term that describes all kinds of non-production-ready, fake dependencies in a test.

  • Mock只是这种依赖的一种。

  • Mock is just one kind of such dependencies.

下一个清单显示了测试在Customer与其合作者隔离后的样子Store

The next listing shows how the tests look after isolating Customer from its collaborator, Store.

清单 2.2。使用伦敦风格的单元测试编写的测试

Listing 2.2. Tests written using the London style of unit testing

[事实]
public void Purchase_succeeds_when_enough_inventory()
{
    // 安排
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .返回(真);
    var customer = new Customer();

    // 行为
    布尔成功 = customer.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    //断言
    断言。真(成功);
    storeMock.验证(
        x => x.RemoveInventory(Product.Shampoo, 5),
        次.Once);
}

[事实]
public void Purchase_fails_when_not_enough_inventory()
{
    // 安排
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .返回(假);
    var customer = new Customer();

    // 行为
    布尔成功 = customer.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    //断言
    断言.False(成功);
    storeMock.验证(
        x => x.RemoveInventory(Product.Shampoo, 5),
        次。从不);
}
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(true);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    // Assert
    Assert.True(success);
    storeMock.Verify(
        x => x.RemoveInventory(Product.Shampoo, 5),
        Times.Once);
}

[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
    // Arrange
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(false);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    // Assert
    Assert.False(success);
    storeMock.Verify(
        x => x.RemoveInventory(Product.Shampoo, 5),
        Times.Never);
}

请注意这些测试与以经典风格编写的测试有何不同。在安排阶段,测试不再实例化 的生产就绪实例,Store而是使用 Moq 的内置类为其创建替代Mock<T>

Note how different these tests are from those written in the classical style. In the arrange phase, the tests no longer instantiate a production-ready instance of Store but instead create a substitution for it, using Moq’s built-in class Mock<T>.

Store此外,我们没有通过向其添加洗发水库存来修改状态,而是直接告诉模拟如何响应对调用的调用HasEnoughInventory()。模拟以测试需要的方式对此请求作出反应,而不管Store. 事实上,测试不再使用Store ——我们已经引入了一个IStore接口并且正在模拟该接口而不是Store类。

Furthermore, instead of modifying the state of Store by adding a shampoo inventory to it, we directly tell the mock how to respond to calls to HasEnoughInventory(). The mock reacts to this request the way the tests need, regardless of the actual state of Store. In fact, the tests no longer use Store — we have introduced an IStore interface and are mocking that interface instead of the Store class.

在第 8 章中,我详细介绍了如何使用接口。现在,请注意需要接口来将被测系统与其协作者隔离开来。(你也可以模拟一个具体的类,但这是一种反模式;我在第 11 章讨论了这个主题。)

In chapter 8, I write in detail about working with interfaces. For now, just make a note that interfaces are required for isolating the system under test from its collaborators. (You can also mock a concrete class, but that’s an anti-pattern; I cover this topic in chapter 11.)

断言阶段也发生了变化,这就是关键区别所在。我们仍然像以前一样检查输出customer.Purchase,但我们验证客户对商店的行为是否正确的方式有所不同。以前,我们通过对商店的状态进行断言来做到这一点。Customer现在,我们检查和之间的交互Store:测试检查客户是否对商店进行了正确的呼叫。我们通过传递客户应该在商店 ( ) 上调用的方法x.RemoveInventory以及它应该调用的次数来实现这一点。如果购买成功,客户应调用此方法一次 ( Times.Once)。如果购买失败,客户根本不应该调用它 ( Times.Never)。

The assertion phase has changed too, and that’s where the key difference lies. We still check the output from customer.Purchase as before, but the way we verify that the customer did the right thing to the store is different. Previously, we did that by asserting against the store’s state. Now, we examine the interactions between Customer and Store: the tests check to see if the customer made the correct call on the store. We do this by passing the method the customer should call on the store (x.RemoveInventory) as well as the number of times it should do that. If the purchases succeeds, the customer should call this method once (Times.Once). If the purchases fails, the customer shouldn’t call it at all (Times.Never).

2.1.2 隔离问题:经典的take

2.1.2  The isolation issue: The classical take

重申一下,伦敦风格通过在测试替身的帮助下将被测代码与其协作者隔离来满足隔离要求:具体来说,模拟。有趣的是,这种观点也会影响你对什么构成一小段代码(一个单元)的看法。以下是单元测试的所有属性:

To reiterate, the London style approaches the isolation requirement by segregating the piece of code under test from its collaborators with the help of test doubles: specifically, mocks. Interestingly enough, this point of view also affects your standpoint on what constitutes a small piece of code (a unit). Here are all the attributes of a unit test once again:

  • 单元测试验证一小段代码(一个单元),

  • A unit test verifies a small piece of code (a unit),

  • 做得很快,

  • Does it quickly,

  • 并以孤立的方式。

  • And in an isolated manner.

除了第三个属性留有解释空间外,第一个属性的可能解释也有一些空间。一小段代码应该有多小?正如您在上一节中看到的,如果您采取隔离每个单独类的立场,那么很自然地接受被测代码段也应该是一个类,或者该类中的一个方法。由于您处理隔离问题的方式,它不能超过这个数。在某些情况下,您可能会同时测试几个类;但总的来说,您将始终努力保持一次对一个类进行单元测试的准则。

In addition to the third attribute leaving room for interpretation, there’s some room in the possible interpretations of the first attribute as well. How small should a small piece of code be? As you saw from the previous section, if you adopt the position of isolating every individual class, then it’s natural to accept that the piece of code under test should also be a single class, or a method inside that class. It can’t be more than that due to the way you approach the isolation issue. In some cases, you might test a couple of classes at once; but in general, you’ll always strive to maintain this guideline of unit testing one class at a time.

正如我之前提到的,还有另一种解释隔离属性的方法——经典方法。在经典方法中,需要以隔离方式测试的不是代码。相反,单元测试本身应该彼此隔离运行。这样,您就可以并行、按顺序、以任何顺序运行测试,只要最适合您,它们仍然不会影响彼此的结果。

As I mentioned earlier, there’s another way to interpret the isolation attribute — the classical way. In the classical approach, it’s not the code that needs to be tested in an isolated manner. Instead, unit tests themselves should be run in isolation from each other. That way, you can run the tests in parallel, sequentially, and in any order, whatever fits you best, and they still won’t affect each other’s outcome.

将测试彼此隔离意味着可以同时执行多个类,只要它们都驻留在内存中并且不会达到共享状态,测试可以通过共享状态进行通信并影响彼此的执行上下文。这种共享状态的典型示例是进程外依赖项——数据库、文件系统等。

Isolating tests from each other means it’s fine to exercise several classes at once as long as they all reside in the memory and don’t reach out to a shared state, through which the tests can communicate and affect each other’s execution context. Typical examples of such a shared state are out-of-process dependencies — the database, the file system, and so on.

例如,一个测试可以在数据库中创建一个客户作为其安排阶段的一部分,而另一个测试将在第一个测试完成执行之前将其删除作为其自己的安排阶段的一部分。如果并行运行这两个测试,第一个测试将失败,不是因为生产代码被破坏,而是因为第二个测试的干扰。

For instance, one test could create a customer in the database as part of its arrange phase, and another test would delete it as part of its own arrange phase, before the first test completes executing. If you run these two tests in parallel, the first test will fail, not because the production code is broken, but rather because of the interference from the second test.

共享、私有和进程外依赖

Shared, private, and out-of-process dependencies

共享依赖关系是在测试之间共享的依赖关系,并为这些测试提供影响彼此结果的方法。共享依赖项的典型示例是静态可变字段。在同一进程中运行的所有单元测试中,对此类字段的更改都是可见的。数据库是共享依赖的另一个典型例子。

A shared dependency is a dependency that is shared between tests and provides means for those tests to affect each other’s outcome. A typical example of shared dependencies is a static mutable field. A change to such a field is visible across all unit tests running within the same process. A database is another typical example of a shared dependency.

私有依赖项是不共享的依赖项。

A private dependency is a dependency that is not shared.

进程外依赖是在应用程序执行进程之外运行的依赖;它是尚未在内存中的数据的代理。在绝大多数情况下,进程外依赖对应于共享依赖,但并非总是如此。例如,一个数据库既是进程外的又是共享的。但是,如果您在每次测试运行之前在 Docker 容器中启动该数据库,这将使该依赖项处于进程外但不共享,因为测试不再适用于它的同一个实例。同样,只读数据库也是进程外但不共享的,即使它被测试重用也是如此。测试无法改变此类数据库中的数据,因此不会影响彼此的结果。

An out-of-process dependency is a dependency that runs outside the application’s execution process; it’s a proxy to data that is not yet in the memory. An out-of-process dependency corresponds to a shared dependency in the vast majority of cases, but not always. For example, a database is both out-of-process and shared. But if you launch that database in a Docker container before each test run, that would make this dependency out-of-process but not shared, since tests no longer work with the same instance of it. Similarly, a read-only database is also out-of-process but not shared, even if it’s reused by tests. Tests can’t mutate data in such a database and thus can’t affect each other’s outcome.

这种对隔离问题的看法需要对模拟和其他测试替身的使用持更温和的看法。您仍然可以使用它们,但通常只对那些在测试之间引入共享状态的依赖项执行此操作。图2.3显示了它的外观。

This take on the isolation issue entails a much more modest view on the use of mocks and other test doubles. You can still use them, but you normally do that for only those dependencies that introduce a shared state between tests. Figure 2.3 shows how it looks.

图 2.3。将单元测试彼此隔离需要将被测类仅与共享依赖项隔离。私有依赖关系可以保持不变。

Figure 2.3. Isolating unit tests from each other entails isolating the class under test from shared dependencies only. Private dependencies can be kept intact.

CH02 图3 隔离测试2

请注意,共享依赖项是在单元测试之间共享的,而不是在被测类(单元)之间共享的。从这个意义上说,只要您能够在每个测试中创建它的新实例,就不会共享单例依赖项。虽然在生产代码中只有一个单例实例,但测试很可能不会遵循这种模式并且不会重用该单例。因此,这种依赖将是私有的。

Note that shared dependencies are shared between unit tests, not between classes under test (units). In that sense, a singleton dependency is not shared as long as you are able to create a new instance of it in each test. While there’s only one instance of a singleton in the production code, tests may very well not follow this pattern and not reuse that singleton. Thus, such a dependency would be private.

例如,通常只有一个配置类的实例,它在所有生产代码中重复使用。但是,如果它像所有其他依赖项一样通过构造函数注入到 SUT 中,那么您可以在每个测试中创建它的新实例;您不必在整个测试套件中维护单个实例。但是,您不能创建新的文件系统或数据库;它们必须在测试之间共享或用测试替身代替。

For example, there’s normally only one instance of a configuration class, which is reused across all production code. But if it’s injected into the SUT the way all other dependencies are, say, via a constructor, you can create a new instance of it in each test; you don’t have to maintain a single instance throughout the test suite. You can’t create a new file system or a database, however; they must be either shared between tests or substituted away with test doubles.

共享依赖与易变依赖

Shared vs. volatile dependencies

另一个术语具有相似但不完全相同的含义:易变的依赖性。我推荐Steven van Deursen 和 Mark Seemann 合着的Dependency Injection: Principles, Practices, Patterns(Manning Publications,2018 年)作为有关依赖管理主题的入门书籍。

Another term has a similar, yet not identical, meaning: volatile dependency. I recommend Dependency Injection: Principles, Practices, Patterns by Steven van Deursen and Mark Seemann (Manning Publications, 2018) as a go-to book on the topic of dependency management.

易失性依赖项是具有以下属性之一的依赖项:

A volatile dependency is a dependency that exhibits one of the following properties:

  • 除了默认安装在开发人员机器上的内容之外,它还引入了设置和配置运行时环境的要求

    数据库和 API 服务就是很好的例子。它们需要额外的设置,默认情况下不会安装在您组织的机器上。

  • It introduces a requirement to set up and configure a runtime environment in addition to what is installed on a developer’s machine by default.

    Databases and API services are good examples here. They require additional setup and are not installed on machines in your organization by default.

  • 它包含不确定的行为

    一个例子是随机数生成器或返回当前日期和时间的类。这些依赖关系是不确定的,因为它们在每次调用时提供不同的结果。

  • It contains nondeterministic behavior.

    An example would be a random number generator or a class returning the current date and time. These dependencies are non-deterministic because they provide different results on each invocation.

如您所见,共享依赖项和可变依赖项的概念之间存在重叠。例如,对数据库的依赖既是共享的又是易变的。但文件系统并非如此。文件系统不是易失性的,因为它安装在每个开发人员的机器上,并且在绝大多数情况下它的行为是确定的。尽管如此,文件系统还是引入了一种方法,单元测试可以通过这种方法干扰彼此的执行上下文;因此它是共享的。同样,随机数生成器是易变的,但是因为您可以为每个测试提供一个单独的实例,所以它不会被共享。

As you can see, there’s an overlap between the notions of shared and volatile dependencies. For example, a dependency on the database is both shared and volatile. But that’s not the case for the file system. The file system is not volatile because it is installed on every developer’s machine and it behaves deterministically in the vast majority of cases. Still, the file system introduces a means by which the unit tests can interfere with each other’s execution context; hence it is shared. Likewise, a random number generator is volatile, but because you can supply a separate instance of it to each test, it isn’t shared.

替换共享依赖项的另一个原因是为了提高测试执行速度。共享依赖项几乎总是位于执行过程之外,而私有依赖项通常不会跨越该边界。因此,调用共享依赖项(例如数据库或文件系统)比调用私有依赖项花费更多时间。由于快速运行的必要性是单元测试定义的第二个属性,因此此类调用将具有共享依赖关系的测试推出单元测试领域并进入集成测试领域。我将在本章后面详细讨论集成测试。

Another reason for substituting shared dependencies is to increase the test execution speed. Shared dependencies almost always reside outside the execution process, while private dependencies usually don’t cross that boundary. Because of that, calls to shared dependencies, such as a database or the file system, take more time than calls to private dependencies. And since the necessity to run quickly is the second attribute of the unit test definition, such calls push the tests with shared dependencies out of the realm of unit testing and into the area of integration testing. I talk more about integration testing later in this chapter.

这种隔离的另一种观点也导致了对什么构成单元(一小段代码)的不同看法。一个单元不一定必须限于一个班级。您也可以对一组类进行单元测试,只要它们都不是共享依赖项即可。

This alternative view of isolation also leads to a different take on what constitutes a unit (a small piece of code). A unit doesn’t necessarily have to be limited to a class. You can just as well unit test a group of classes, as long as none of them is a shared dependency.

2.2 经典的和伦敦的单元测试学派

2.2  The classical and London schools of unit testing

如您所见,伦敦学校与古典学校之间差异的根源在于隔离属性。伦敦学派将其视为被测系统与其合作者的隔离,而经典学派则将其视为单元测试本身与彼此的隔离。

As you can see, the root of the differences between the London and classical schools is the isolation attribute. The London school views it as isolation of the system under test from its collaborators, whereas the classical school views it as isolation of unit tests themselves from each other.

这种看似微小的差异导致了关于如何进行单元测试的巨大分歧,正如您已经知道的那样,产生了两种思想流派。总的来说,学校之间的分歧涵盖三个主要主题:

This seemingly minor difference has led to a vast disagreement about how to approach unit testing, which, as you already know, produced the two schools of thought. Overall, the disagreement between the schools spans three major topics:

  • 隔离要求

  • The isolation requirement

  • 什么构成一段被测代码(一个单元)

  • What constitutes a piece of code under test (a unit)

  • 处理依赖关系

  • Handling dependencies

2.1总结了这一切。

Table 2.1 sums it all up.

表 2.1。伦敦单元测试学派和经典单元测试学派之间的差异,总结为隔离方法、单元的大小和测试替身的使用

Table 2.1. The differences between the London and classical schools of unit testing, summed up by the approach to isolation, the size of a unit, and the use of test doubles

  的隔离。. . 一个单位是。. . 对 . 使用测试替身。. .

伦敦学校

London school

单位

Units

一类

A class

除了不可变的依赖项

All but immutable dependencies

古典派

Classical school

单元测试

Unit tests

一个类或一组类

A class or a set of classes

共享依赖

Shared dependencies

2.2.1 古典学派和伦敦学派如何处理依赖关系

2.2.1  How the classical and London schools handle dependencies

请注意,尽管测试替身的使用无处不在,但伦敦学校仍然允许在测试中按原样使用某些依赖项。这里的试金石是依赖项是否可变。最好不要替换永远不会改变的对象—— 不可变对象。

Note that despite the ubiquitous use of test doubles, the London school still allows for using some dependencies in tests as is. The litmus test here is whether a dependency is mutable. It’s fine not to substitute objects that don’t ever change — immutable objects.

你在前面的例子中看到,当我将测试重构为伦敦风格时,我没有用Product模拟替换实例,而是使用了真实的对象,如以下代码所示(为方便起见,重复了清单2.2 ) :

And you saw in the earlier examples that, when I refactored the tests toward the London style, I didn’t replace the Product instances with mocks but rather used the real objects, as shown in the following code (repeated from listing 2.2 for your convenience):

[事实]
public void Purchase_fails_when_not_enough_inventory()
{
    // 安排
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .返回(假);
    var customer = new Customer();

    // 行为
    bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);

    //断言
    断言.False(成功);
    storeMock.验证(
        x => x.RemoveInventory(Product.Shampoo, 5),
        次。从不);
}
[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
    // Arrange
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(false);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(storeMock.Object, Product.Shampoo, 5);

    // Assert
    Assert.False(success);
    storeMock.Verify(
        x => x.RemoveInventory(Product.Shampoo, 5),
        Times.Never);
}

在 的两个依赖项中Customer,仅Store包含一个可以随时间变化的内部状态。实例Product是不可变的(Product它本身是一个 C# 枚举)。因此我Store只替换了实例。

Of the two dependencies of Customer, only Store contains an internal state that can change over time. The Product instances are immutable (Product itself is a C# enum). Hence I substituted the Store instance only.

如果您考虑一下,这是有道理的。5您也不会在之前的测试中对数字使用测试替身,对吗?那是因为它也是不可变的——你不可能修改这个数字。请注意,我不是在谈论包含数字的变量,而是数字本身。在语句中RemoveInventory(Product.Shampoo, 5),我们甚至不使用变量;5立即宣布。对于 也是如此Product.Shampoo

It makes sense, if you think about it. You wouldn’t use a test double for the 5 number in the previous test either, would you? That’s because it is also immutable — you can’t possibly modify this number. Note that I’m not talking about a variable containing the number, but rather the number itself. In the statement RemoveInventory(Product.Shampoo, 5), we don’t even use a variable; 5 is declared right away. The same is true for Product.Shampoo.

这种不可变对象称为值对象。他们的主要特点是没有个人身份;它们仅通过其内容来识别。作为必然结果,如果两个这样的对象具有相同的内容,那么您正在使用它们中的哪一个并不重要:这些实例是可以互换的。例如,如果你有两个5整数,你可以用它们代替另一个。我们案例中的产品也是如此:您可以重用单个Product.Shampoo实例或声明其中的多个实例——这不会有任何区别。这些实例将具有相同的内容,因此可以互换使用。

Such immutable objects are called value objects or values. Their main trait is that they have no individual identity; they are identified solely by their content. As a corollary, if two such objects have the same content, it doesn’t matter which of them you’re working with: these instances are interchangeable. For example, if you’ve got two 5 integers, you can use them in place of one another. The same is true for the products in our case: you can reuse a single Product.Shampoo instance or declare several of them — it won’t make any difference. These instances will have the same content and thus can be used interchangeably.

请注意,值对象的概念与语言无关,不需要特定的编程语言或框架。您可以在我的文章“实体与值对象:差异的最终列表”中阅读更多关于值对象的信息,网址为http://mng.bz/KE9O

Note that the concept of a value object is language-agnostic and doesn’t require a particular programming language or framework. You can read more about value objects in my article "Entity vs. Value Object: the ultimate list of differences" at http://mng.bz/KE9O.

2.4显示了依赖关系的分类以及两种单元测试流派如何对待它们。依赖项可以是sharedprivate。反过来,私有依赖项可以是可变不可变。在后一种情况下,它被称为值对象。例如,数据库是一个共享的依赖项——它的内部状态在所有自动化测试中共享(不会用测试替身替换它)。实例是可变的私有依赖项。和一个实例(或一个数字的实例StoreProduct5,就此而言)是一个不可变的私有依赖的例子——一个值对象。所有共享的依赖项都是可变的,但是要共享一个可变的依赖项,它必须被测试重用。

Figure 2.4 shows the categorization of dependencies and how both schools of unit testing treat them. A dependency can be either shared or private. A private dependency, in turn, can be either mutable or immutable. In the latter case, it is called a value object. For example, a database is a shared dependency — its internal state is shared across all automated tests (that don’t replace it with a test double). A Store instance is a private dependency that is mutable. And a Product instance (or an instance of a number 5, for that matter) is an example of a private dependency that is immutable — a value object. All shared dependencies are mutable, but for a mutable dependency to be shared, it has to be reused by tests.

图 2.4。依赖关系的层次结构。经典学派提倡用测试替身代替共享依赖。伦敦学派也提倡替换私有依赖项,只要它们是可变的。

Figure 2.4. The hierarchy of dependencies. The classical school advocates for replacing shared dependencies with test doubles. The London school advocates for the replacement of private dependencies as well, as long as they are mutable.

CH02 图4 相依性

为了您的方便,我重复表2.1以及学校之间的差异:

I’m repeating table 2.1 with the differences between the schools for your convenience:

  的隔离。. . 一个单位是。. . 使用测试替身

伦敦学校

London school

单位

Units

一类

A class

除了不可变的依赖项

All but immutable dependencies

古典派

Classical school

单元测试

Unit tests

一个类或一组类

A class or a set of classes

共享依赖

Shared dependencies

经典学派提倡用测试替身替换大部分共享依赖,而伦敦学派也主张替换私有依赖,只要它们是可变的。

The classical school advocates for replacing mostly shared dependencies with test doubles, while the London school stands for the replacement of private dependencies as well, as long as they are mutable.

合作者与依赖

Collaborator vs. dependency

协作者是共享或可变的依赖项例如,提供对数据库的访问的类是协作者,因为数据库是共享依赖项。Store也是一个合作者,因为它的状态会随着时间而改变。

A collaborator is a dependency that is either shared or mutable. For example, a class providing access to the database is a collaborator since the database is a shared dependency. Store is a collaborator too, because its state can change over time.

Product和 number5也是依赖关系,但它们不是合作者。它们是值对象

Product and number 5 are also dependencies, but they’re not collaborators. They’re values or value objects.

一个典型的类可能与两种类型的依赖项一起工作:合作者价值观。看看这个方法调用:

A typical class may work with dependencies of both types: collaborators and values. Look at this method call:

customer.Purchase(store, Product.Shampoo, 5)
customer.Purchase(store, Product.Shampoo, 5)

这里我们有三个依赖项。其中一个 ( store) 是合作者,另外两个 ( Product.Shampoo, 5) 不是。

Here we have three dependencies. One of them (store) is a collaborator, and the other two (Product.Shampoo, 5) are not.

让我重申关于依赖类型的一点。并非所有的进程外依赖都属于共享依赖的范畴。共享依赖项几乎总是位于应用程序进程之外,但反之则不然(见图2.5)。为了共享进程外依赖性,它必须为单元测试提供相互通信的方法。通信是通过修改依赖项的内部状态来完成的。从这个意义上说,不可变的进程外依赖不提供这种方法。这些测试根本无法修改其中的任何内容,因此不会干扰彼此的执行上下文。

And let me reiterate one point about the types of dependencies. Not all out-of-process dependencies fall into the category of shared dependencies. A shared dependency almost always resides outside the application’s process, but the opposite isn’t true (see figure 2.5). In order for an out-of-process dependency to be shared, it has to provide means for unit tests to communicate with each other. The communication is done through modifications of the dependency’s internal state. In that sense, an immutable out-of-process dependency doesn’t provide such a means. The tests simply can’t modify anything in it and thus can’t interfere with each other’s execution context.

例如,如果某处有一个 API 可以返回组织销售的所有产品的目录,只要该 API 不公开更改目录的功能,这就不是共享依赖项。确实,这样的依赖关系是易变的并且位于应用程序的边界之外,但是由于测试不会影响它返回的数据,因此它不会被共享。这并不意味着您必须在测试范围中包含此类依赖项。在大多数情况下,您仍然需要将其替换为测试替身以保持测试速度。但是,如果进程外依赖足够快并且与它的连接稳定,那么您可以在测试中很好地使用它。

For example, if there’s an API somewhere that returns a catalog of all products the organization sells, this isn’t a shared dependency as long as the API doesn’t expose the functionality to change the catalog. It’s true that such a dependency is volatile and sits outside the application’s boundary, but since the tests can’t affect the data it returns, it isn’t shared. This doesn’t mean you have to include such a dependency in the testing scope. In most cases, you still need to replace it with a test double to keep the test fast. But if the out-of-process dependency is quick enough and the connection to it is stable, you can make a good case for using it as is in the tests.

图 2.5。共享和进程外依赖关系之间的关系。共享但不是进程外的依赖项的示例是单例(所有测试重用的实例)或类中的静态字段。数据库是共享的和进程外的——它位于主进程之外并且是可变的。只读 API 在进程外但不共享,因为测试无法修改它,因此不会影响彼此的执行流程。

Figure 2.5. The relation between shared and out-of-process dependencies. An example of a dependency that is shared but not out-of-process is a singleton (an instance that is reused by all tests) or a static field in a class. A database is shared and out-of-process — it resides outside the main process and is mutable. A read-only API is out-of-process but not shared, since tests can’t modify it and thus can’t affect each other’s execution flow.

CH02 FIG 5分享出流程

话虽如此,在本书中,我交替使用共享依赖进程外依赖这两个术语,除非我另有明确说明。在实际项目中,您很少有非进程外的共享依赖项。如果依赖项在进程中,您可以轻松地为每个测试提供一个单独的实例;无需在测试之间共享它。同样,您通常不会遇到未共享的进程外依赖项。大多数此类依赖项是可变的,因此可以通过测试进行修改。

Having that said, in this book, I use the terms shared dependency and out-of-process dependency interchangeably unless I explicitly state otherwise. In real-world projects, you rarely have a shared dependency that isn’t out-of-process. If a dependency is in-process, you can easily supply a separate instance of it to each test; there’s no need to share it between tests. Similarly, you normally don’t encounter an out-of-process dependency that’s not shared. Most such dependencies are mutable and thus can be modified by tests.

有了这个定义的基础,让我们对比一下这两个学校的优点。

With this foundation of definitions, let’s contrast the two schools on their merits.

2.3 对比经典单元测试和伦敦单元测试学派

2.3  Contrasting the classical and London schools of unit testing

重申一下,古典学派和伦敦学派之间的主要区别在于它们如何处理单元测试定义中的隔离问题。这反过来又会影响到单元的处理—— 应该被测试的东西——以及处理依赖关系的方法。

To reiterate, the main difference between the classical and London schools is in how they treat the isolation issue in the definition of a unit test. This, in turn, spills over to the treatment of a unit — the thing that should be put under test — and the approach to handling dependencies.

正如我之前提到的,我更喜欢经典的单元测试学派。它倾向于产生更高质量的测试,因此更适合实现单元测试的最终目标,即项目的可持续增长。原因是脆弱性:使用模拟的测试往往比经典测试更脆弱(第 5 章对此有更多介绍)。现在,让我们把伦敦学派的主要卖点一一评价一下。

As I mentioned previously, I prefer the classical school of unit testing. It tends to produce tests of higher quality and thus is better suited for achieving the ultimate goal of unit testing, which is the sustainable growth of your project. The reason is fragility: tests that use mocks tend to be more brittle than classical tests (more on this in chapter 5). For now, let’s take the main selling points of the London school and evaluate them one by one.

伦敦学校的方法提供了以下好处:

The London school’s approach provides the following benefits:

  • 更好的粒度。测试是细粒度的,一次只检查一个类。

  • Better granularity. The tests are fine grained and check only one class at a time.

  • 对更大的互连类图进行单元测试更容易。由于所有协作者都被测试替身取代,因此您在编写测试时无需担心他们。

  • It’s easier to unit test a larger graph of interconnected classes. Since all collaborators are replaced by test doubles, you don’t need to worry about them at the time of writing the test.

  • 如果测试失败,您肯定知道哪个功能失败了。没有班级的合作者,除了被测班级本身之外,不可能有任何嫌疑人。

    当然,仍然可能存在被测系统使用了一个值对象的情况,正是这个值对象的变化导致测试失败。但是这些情况并不常见,因为在测试中消除了所有其他依赖项。

  • If a test fails, you know for sure which functionality has failed. Without the class’s collaborators, there could be no suspects other than the class under test itself.

    Of course, there may still be situations where the system under test uses a value object and it’s the change in this value object that makes the test fail. But these cases aren’t that frequent because all other dependencies are eliminated in tests.

2.3.1 一次单元测试一个类

2.3.1  Unit testing one class at a time

关于更好粒度的观点与单元测试中单元构成的讨论有关。伦敦学派将班级视为这样一个单元。来自面向对象编程背景的开发人员通常将类视为位于每个代码库基础上的原子构建块。这自然会导致将类也视为要在测试中验证的原子单元。这种倾向是可以理解的,但具有误导性。

The point about better granularity relates to the discussion about what constitutes a unit in unit testing. The London school considers a class as such a unit. Coming from an object-oriented programming background, developers usually regard classes as the atomic building blocks that lie at the foundation of every code base. This naturally leads to treating classes as the atomic units to be verified in tests, too. This tendency is understandable but misleading.

 

 

[提示] 提示

测试不应该验证代码单元。相反,他们应该验证行为单元:对问题域有意义的东西,理想情况下,业务人员可以认为有用的东西。实现这样一个行为单元所需要的类的数量是无关紧要的。它可以跨越多个类,也可以只跨越一个类,甚至只占用一个很小的方法。

Tests shouldn’t verify units of code. Rather, they should verify units of behavior: something that is meaningful for the problem domain and, ideally, something that a business person can recognize as useful. The number of classes it takes to implement such a unit of behavior is irrelevant. It could span across multiple classes, only one class, or even take up just a tiny method.

因此,以更好的代码粒度为目标是没有帮助的。只要测试检查单个行为单元,它就是一个好的测试。定位低于此的目标实际上会损害您的单元测试,因为很难准确理解这些测试验证的内容。测试应该讲述一个关于您的代码帮助解决的问题的故事,这个故事对于非程序员来说应该是连贯的和有意义的

And so, aiming at better code granularity isn’t helpful. As long as the test checks a single unit of behavior, it’s a good test. Targeting something less than that can in fact damage your unit tests, as it becomes harder to understand exactly what these tests verify. A test should tell a story about the problem your code helps to solve, and this story should be cohesive and meaningful to a non-programmer.

例如,这是一个有凝聚力的故事的例子:

For instance, this is an example of a cohesive story:

当我叫我的狗时,他会马上来找我。
When I call my dog, he comes right to me.

现在将其与以下内容进行比较:

Now compare it to the following:

当我叫我的狗时,他先是左前腿,然后是右前腿,他的头转动,尾巴开始摇摆......
When I call my dog, he moves his front left leg first, then the front right leg, his head turns, the tail start wagging...

第二个故事意义不大。所有这些运动的目的是什么?狗来找我了吗?还是他在逃?你不知道。当您针对单个类(狗的腿、头和尾巴)而不是实际行为(狗来到他的主人身边)时,您的测试就是这样开始的。我将在第 5 章详细讨论可观察行为这个主题以及如何将其与内部实现细节区分开来。

The second story makes much less sense. What’s the purpose of all those movements? Is the dog coming to me? Or is he running away? You can’t tell. This is what your tests start to look like when you target individual classes (the dog’s legs, head, and tail) instead of the actual behavior (the dog coming to his master). I talk more about this topic of observable behavior and how to differentiate it from internal implementation details in chapter 5.

2.3.2 对互连类的大图进行单元测试

2.3.2  Unit testing a large graph of interconnected classes

使用模拟代替真正的合作者可以更容易地测试一个类——尤其是当有一个复杂的依赖关系图时,被测类有依赖关系,每个依赖关系都依赖于它自己的依赖关系,等等,几个层深的。使用测试替身,您可以替换类的直接依赖关系,从而分解图形,这可以显着减少您在单元测试中必须做的准备工作。如果您遵循经典学派,您必须重新创建完整的对象图(共享依赖项除外)只是为了设置被测系统,这可能需要大量工作。

The use of mocks in place of real collaborators can make it easier to test a class — especially when there’s a complicated dependency graph, where the class under test has dependencies, each of which relies on dependencies of its own, and so on, several layers deep. With test doubles, you can substitute the class’s immediate dependencies and thus break up the graph, which can significantly reduce the amount of preparation you have to do in a unit test. If you follow the classical school, you have to re-create the full object graph (with the exception of shared dependencies) just for the sake of setting up the system under test, which can be a lot of work.

虽然这一切都是真的,但这种推理集中在错误的问题上。与其寻找方法来测试大型、复杂的互连类图,不如首先关注不要有这样的类图。通常情况下,大型类图是代码设计问题的结果。

Although this is all true, this line of reasoning focuses on the wrong problem. Instead of finding ways to test a large, complicated graph of interconnected classes, you should focus on not having such a graph of classes in the first place. More often than not, a large class graph is a result of a code design problem.

测试指出这个问题实际上是一件好事。正如我们在第 1 章中讨论的那样,对一段代码进行单元测试的能力是一个很好的负面指标——它以相对较高的精度预测较差的代码质量。如果您看到要对一个类进行单元测试,您需要将测试的安排阶段延长到所有合理的限制之外,这是一个麻烦的迹象。使用模拟只是隐藏了这个问题;它没有解决根本原因。我在第 2 部分中讨论了如何解决底层代码设计问题。

And it’s actually a good thing that the tests point out this problem. As we discussed in chapter 1, the ability to unit test a piece of code is a good negative indicator — it predicts poor code quality with a relatively high precision. If you see that to unit test a class, you need to extend the test’s arrange phase beyond all reasonable limits, it’s a certain sign of trouble. The use of mocks only hides this problem; it doesn’t tackle the root cause. I talk about how to fix the underlying code design problem in part 2.

2.3.3 揭示准确的bug位置

2.3.3  Revealing the precise bug location

如果您将错误引入具有伦敦式测试的系统,通常只会导致其 SUT 包含该错误的测试失败。然而,使用经典方法,针对故障类的客户端的测试也可能会失败。这会导致连锁反应,其中单个错误可能会导致整个系统的测试失败。结果,找到问题的根源变得更加困难。您可能需要花一些时间调试测试才能弄明白。

If you introduce a bug to a system with London-style tests, it normally causes only tests whose SUT contains the bug to fail. However, with the classical approach, tests that target the clients of the malfunctioning class can also fail. This leads to a ripple effect where a single bug can cause test failures across the whole system. As a result, it becomes harder to find the root of the issue. You might need to spend some time debugging the tests to figure it out.

这是一个合理的担忧,但我不认为这是一个大问题。如果您定期运行测试(理想情况下,在每次源代码更改后),那么您就会知道导致错误的原因——这是您最后编辑的内容,因此找到问题并不难。此外,您不必查看所有失败的测试。修复一个会自动修复所有其他问题。

It’s a valid concern, but I don’t see it as a big problem. If you run your tests regularly (ideally, after each source code change), then you know what caused the bug — it’s what you edited last, so it’s not that difficult to find the issue. Also, you don’t have to look at all the failing tests. Fixing one automatically fixes all the others.

此外,在整个测试套件中级联的故障也有一定的价值。如果一个错误不仅在一个测试中导致错误,而且在很多测试中导致错误,这表明您刚刚破坏的代码片段具有很大的价值——整个系统都依赖于它。这是使用代码时要记住的有用信息。

Furthermore, there’s some value in failures cascading all over the test suite. If a bug leads to a fault in not only one test but a whole lot of them, it shows that the piece of code you have just broken is of great value — the entire system depends on it. That’s useful information to keep in mind when working with the code.

2.3.4 古典学派与伦敦学派的其他差异

2.3.4  Other differences between the classical and London schools

古典学派和伦敦学派之间剩下的两个区别是

Two remaining differences between the classical and London schools are

  • 他们通过测试驱动开发 (TDD) 进行系统设计的方法

  • Their approach to system design with test-driven development (TDD)

  • 过度规范的问题

  • The issue of over-specification

测试驱动开发

Test-driven development

测试驱动开发是依靠测试来驱动项目开发的软件开发过程。该过程由三个(有些作者指定为四个)阶段组成,您对每个测试用例重复这些阶段:

Test-driven development is a software development process that relies on tests to drive the project development. The process consists of three (some authors specify four) stages, which you repeat for every test case:

  1. 编写一个失败的测试来指示需要添加哪些功能以及它应该如何运行。

  2. Write a failing test to indicate which functionality needs to be added and how it should behave.

  3. 编写足够的代码来使测试通过。在这个阶段,代码不必优雅或干净。

  4. Write just enough code to make the test pass. At this stage, the code doesn’t have to be elegant or clean.

  5. 重构代码。在通过测试的保护下,您可以放心地清理代码,使其更具可读性和可维护性。

  6. Refactor the code. Under the protection of the passing test, you can safely clean up the code to make it more readable and maintainable.

关于这个主题的好资料是我之前推荐的两本书:Kent Beck 的测试驱动开发:通过示例Growing Object-Oriented Software, Guided by Tests 作者:Steve Freeman 和 Nat Pryce。

Good sources on this topic are the two books I recommended earlier: Kent Beck’s Test-Driven Development: By Example and Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce.

伦敦风格的单元测试导致由外而内的 TDD,您从更高级别的测试开始,为整个系统设定期望。通过使用模拟,您可以指定系统应与哪些协作者进行通信以实现预期结果。然后,您将逐步完成类图,直到实现每一个类。模拟使这个设计过程成为可能,因为你可以一次专注于一个类。您可以在测试时切断 SUT 的所有协作者,从而将这些协作者的实现推迟到以后的时间。

The London style of unit testing leads to outside-in TDD, where you start from the higher-level tests that set expectations for the whole system. By using mocks, you specify which collaborators the system should communicate with to achieve the expected result. You then work your way through the graph of classes until you implement every one of them. Mocks make this design process possible because you can focus on one class at a time. You can cut off all of the SUT’s collaborators when testing it and thus postpone implementing those collaborators to a later time.

经典学校没有提供完全相同的指导,因为你必须在测试中处理真实的对象。相反,您通常使用由内而外的方法。在这种风格中,您从领域模型开始,然后在其之上放置额外的层,直到最终用户可以使用该软件。

The classical school doesn’t provide quite the same guidance since you have to deal with the real objects in tests. Instead, you normally use the inside-out approach. In this style, you start from the domain model and then put additional layers on top of it until the software becomes usable by the end user.

但这些学校之间最重要的区别是过度规范问题:即将测试与 SUT 的实施细节相结合。伦敦风格倾向于产生比经典风格更频繁地耦合到实现的测试。这是反对无处不在使用模拟和一般伦敦风格的主要反对意见。

But the most crucial distinction between the schools is the issue of over-specification: that is, coupling the tests to the SUT’s implementation details. The London style tends to produce tests that couple to the implementation more often than the classical style. And this is the main objection against the ubiquitous use of mocks and the London style in general.

嘲笑的话题还有很多。从第 4 章开始,我逐渐涵盖了与之相关的所有内容。

There’s much more to the topic of mocking. Starting with chapter 4, I gradually cover everything related to it.

2.5 两校融合测试

2.5  Integration tests in the two schools

伦敦学校和古典学校在综合测试的定义上也存在分歧。这种分歧自然源于他们在隔离问题上的不同看法。

The London and classical schools also diverge in their definition of an integration test. This disagreement flows naturally from the difference in their views on the isolation issue.

伦敦学派将任何使用真实合作者对象的测试都视为集成测试。大多数以古典风格编写的测试将被伦敦学派的支持者视为综合测试。例如,参见清单Listing 1.1,其中我首先介绍了涵盖客户购买功能的两个测试。从经典的角度来看,该代码是典型的单元测试,但它是伦敦学派追随者的集成测试。

The London school considers any test that uses a real collaborator object an integration test. Most of the tests written in the classical style would be deemed integration tests by the London school proponents. For an example, see listing Listing 1.1, in which I first introduced the two tests covering the customer purchase functionality. That code is a typical unit test from the classical perspective, but it’s an integration test for a follower of the London school.

在本书中,我使用了单元测试和集成测试的经典定义。同样,单元测试是具有以下特征的自动化测试:

In this book, I use the classical definitions of both unit and integration testing. Again, a unit test is an automated test that has the following characteristics:

  • 它验证了一小段代码,

  • It verifies a small piece of code,

  • 做得很快,

  • Does it quickly,

  • 并以孤立的方式。

  • And in an isolated manner.

既然我已经阐明了第一和第三属性的含义,我将从古典学派的角度重新定义它们。单元测试是这样的测试

Now that I’ve clarified what the first and third attributes mean, I’ll redefine them from the point of view of the classical school. A unit test is a test that

  • 验证单个行为单元

  • Verifies a single unit of behavior,

  • 做得很快,

  • Does it quickly,

  • 与其他测试隔离开来。

  • And in isolation from other tests.

因此,集成测试是不满足这些标准之一的测试。例如,涉及共享依赖项(例如数据库)的测试无法独立于其他测试运行。如果并行运行,一个测试引入的数据库状态更改将改变依赖同一数据库的所有其他测试的结果。您必须采取额外的步骤来避免这种干扰。特别是,您必须按顺序运行此类测试,以便每个测试都等待轮到它使用共享依赖项。

An integration test, then, is a test that doesn’t meet one of these criteria. For example, a test that reaches out to a shared dependency — say, a database — can’t run in isolation from other tests. A change in the database’s state introduced by one test would alter the outcome of all other tests that rely on the same database if run in parallel. You’d have to take additional steps to avoid this interference. In particular, you would have to run such tests sequentially, so that each test would wait its turn to work with the shared dependency.

同样,对进程外依赖项的扩展会使测试变慢。对数据库的调用会增加数百毫秒,可能高达一秒的额外执行时间。起初,毫秒似乎不是什么大事,但是当您的测试套件增长到足够大时,每一秒都很重要。

Similarly, an outreach to an out-of-process dependency makes the test slow. A call to a database adds hundreds of milliseconds, potentially up to a second, of additional execution time. Milliseconds might not seem like a big deal at first, but when your test suite grows large enough, every second counts.

理论上,您可以编写一个仅适用于内存中对象的慢速测试,但这并不容易。同一内存空间内对象之间的通信比不同进程之间的通信要便宜得多。即使测试使用数百个内存中对象,与它们的通信仍然比调用数据库执行得更快。

In theory, you could write a slow test that works with in-memory objects only, but it’s not that easy to do. Communication between objects inside the same memory space is much less expensive than between separate processes. Even if the test works with hundreds of in-memory objects, the communication with them will still execute faster than a call to a database.

最后,当一个测试验证两个或多个行为单元时,它就是一个集成测试。这通常是试图优化测试套件的执行速度的结果。当您有两个遵循相似步骤但验证不同行为单元的慢速测试时,将它们合并为一个可能是有意义的:一个检查两个相似事物的测试比两个更细粒度的测试运行得更快。但话又说回来,这两个原始测试已经是集成测试了(因为它们很慢),所以这个特性通常不是决定性的。

Finally, a test is an integration test when it verifies two or more units of behavior. This is often a result of trying to optimize the test suite’s execution speed. When you have two slow tests that follow similar steps but verify different units of behavior, it might make sense to merge them into one: one test checking two similar things runs faster than two more-granular tests. But then again, the two original tests would have been integration tests already (due to them being slow), so this characteristic usually isn’t decisive.

集成测试还可以验证由不同团队开发的两个或多个模块如何协同工作。这也属于同时验证多个行为单元的第三类测试。但是同样,因为这样的集成通常需要进程外依赖,所以测试将无法满足所有三个标准,而不仅仅是一个。

An integration test can also verify how two or more modules developed by separate teams work together. This also falls into the third bucket of tests that verify multiple units of behavior at once. But again, because such an integration normally requires an out-of-process dependency, the test will fail to meet all three criteria, not just one.

集成测试通过验证整个系统在提高软件质量方面发挥着重要作用。我在第 3 部分详细介绍了集成测试。

Integration testing plays a significant part in contributing to software quality by verifying the system as a whole. I write about integration testing in detail in part 3.

2.5.1 端到端测试是集成测试的一个子集

2.5.1  End-to-end tests are a subset of integration tests

简而言之,集成测试是一种测试,用于验证您的代码是否可以与共享依赖项、进程外依赖项或组织中其他团队开发的代码集成。还有一个单独的端到端测试概念。端到端测试是集成测试的一个子集。他们也会检查您的代码如何处理进程外依赖项。端到端测试和集成测试之间的区别在于,端到端测试通常包含更多此类依赖项。

In short, an integration test is a test that verifies that your code works in integration with shared dependencies, out-of-process dependencies, or code developed by other teams in the organization. There’s also a separate notion of an end-to-end test. End-to-end tests are a subset of integration tests. They, too, check to see how your code works with out-of-process dependencies. The difference between an end-to-end test and an integration test is that end-to-end tests usually include more of such dependencies.

界限有时是模糊的,但一般来说,集成测试仅适用于一两个进程外依赖项。另一方面,端到端测试适用于所有进程外依赖项,或者适用于其中的绝大多数。因此名称为端到端,这意味着测试从最终用户的角度验证系统,包括该系统集成的所有外部应用程序(见图2.6)。

The line is blurred at times, but in general, an integration test works with only one or two out-of-process dependencies. On the other hand, an end-to-end test works with all out-of-process dependencies, or with the vast majority of them. Hence the name end-to-end, which means the test verifies the system from the end user’s point of view, including all the external applications this system integrates with (see figure 2.6).

图 2.6。端到端测试通常包括范围内的所有或几乎所有进程外依赖项。集成测试只检查一两个这样的依赖关系——那些更容易自动设置的依赖关系,例如数据库或文件系统。

Figure 2.6. End-to-end tests normally include all or almost all out-of-process dependencies in the scope. Integration tests check only one or two such dependencies — those that are easier to set up automatically, such as the database or the file system.

CH02 FIG 6 端到端测试

人们还使用UI 测试(UI 代表用户界面)、GUI 测试(GUI 是图形用户界面)和功能测试等术语。术语定义不明确,但总的来说,这些术语都是同义词。

People also use such terms as UI tests (UI stands for user interface), GUI tests (GUI is graphical user interface), and functional tests. The terminology is ill-defined, but in general, these terms are all synonyms.

假设您的应用程序使用三个进程外依赖项:数据库、文件系统和支付网关。典型的集成测试将仅包括范围内的数据库和文件系统,并使用测试替身来替换支付网关。那是因为您可以完全控制数据库和文件系统,因此可以轻松地将它们带到测试中所需的状态,而您对支付网关没有相同程度的控制。使用支付网关,您可能需要联系支付处理机构来设置一个特殊的测试账户。您可能还需要不时检查该帐户,以手动清理过去测试执行遗留下来的所有付款费用。

Let’s say your application works with three out-of-process dependencies: a database, the file system, and a payment gateway. A typical integration test would include only the database and file system in scope and use a test double to replace the payment gateway. That’s because you have full control over the database and file system, and thus can easily bring them to the required state in tests, whereas you don’t have the same degree of control over the payment gateway. With the payment gateway, you may need to contact the payment processor organization to set up a special test account. You might also need to check that account from time to time to manually clean up all the payment charges left over from the past test executions.

由于端到端测试在维护方面成本最高,因此最好在构建过程的后期运行它们,在所有单元和集成测试都通过之后。您甚至可能只在构建服务器上运行它们,而不是在各个开发人员的机器上运行。

Since end-to-end tests are the most expensive in terms of maintenance, it’s better to run them late in the build process, after all the unit and integration tests have passed. You may possibly even run them only on the build server, not on individual developers' machines.

请记住,即使使用端到端测试,您也可能无法解决所有进程外依赖项。某些依赖项可能没有测试版本,或者可能无法自动将这些依赖项带到所需状态。

Keep in mind that even with end-to-end tests, you might not be able to tackle all of the out-of-process dependencies. There may be no test version of some dependencies, or it may be impossible to bring those dependencies to the required state automatically.

因此,您可能仍需要使用测试替身,以强化集成测试和端到端测试之间没有明确界限的事实。

So you may still need to use a test double, reinforcing the fact that there isn’t a distinct line between integration and end-to-end tests.

2.6 总结

2.6  Summary

  • 在本章中,我细化了单元测试的定义:

    • 单元测试验证单个行为单元,

    • 做得很快,

    • 并与其他测试隔离开来。

  • Throughout this chapter, I’ve refined the definition of a unit test:

    • A unit test verifies a single unit of behavior,

    • Does it quickly,

    • And in isolation from other tests.

  • 隔离问题是争议最大的。争论导致了两个单元测试学派的形成:经典(底特律)学派和伦敦(模拟)学派。这种意见分歧会影响对什么构成单元的看法以及对被测系统 (SUT) 依赖项的处理。

    • 伦敦学派指出,被测单元应该相互隔离。被测单元是一个代码单元,通常是一个类。它的所有依赖项(不可变依赖项除外)都应在测试中替换为测试替身。

    • 古典学派指出单元测试需要相互隔离,而不是单元。此外,被测单元是行为单元,而不是代码单元。因此,只有共享的依赖关系才应该用测试替身代替。共享依赖项是为测试提供影响彼此执行流程的手段的依赖项。

  • The isolation issue is disputed the most. The dispute led to the formation of two schools of unit testing: the classical (Detroit) school, and the London (mockist) school. This difference of opinion affects the view of what constitutes a unit and the treatment of the system under test’s (SUT’s) dependencies.

    • The London school states that the units under test should be isolated from each other. A unit under test is a unit of code, usually a class. All of its dependencies, except immutable dependencies, should be replaced with test doubles in tests.

    • The classical school states that the unit tests need to be isolated from each other, not units. Also, a unit under test is a unit of behavior, not a unit of code. Thus, only shared dependencies should be replaced with test doubles. Shared dependencies are dependencies that provide means for tests to affect each other’s execution flow.

  • 伦敦学派提供了更好的粒度、易于测试互连类的大型图以及在测试失败后易于发现哪些功能包含错误的好处。

  • The London school provides the benefits of better granularity, the ease of testing large graphs of interconnected classes, and the ease of finding which functionality contains a bug after a test failure.

  • 伦敦学校的好处乍一看很吸引人。然而,它们引入了几个问题。首先,对被测类的关注是错误的:测试应该验证行为单元,而不是代码单元。此外,无法对一段代码进行单元测试是代码设计存在问题的强烈迹象。使用测试替身并不能解决这个问题,而只是隐藏它。最后,虽然在测试失败后轻松确定哪些功能包含错误是有帮助的,但这并不是什么大问题,因为您通常知道导致错误的原因 - 这是您最后编辑的内容。

  • The benefits of the London school look appealing at first. However, they introduce several issues. First, the focus on classes under test is misplaced: tests should verify units of behavior, not units of code. Furthermore, the inability to unit test a piece of code is a strong sign of a problem with the code design. The use of test doubles doesn’t fix this problem, but rather only hides it. And finally, while the ease of determining which functionality contains a bug after a test failure is helpful, it’s not that big a deal because you often know what caused the bug anyway — it’s what you edited last.

  • 伦敦单元测试学院最大的问题是过度规范的问题——将测试耦合到 SUT 的实现细节。

  • The biggest issue with the London school of unit testing is the problem of over-specification — coupling tests to the SUT’s implementation details.

  • 集成测试是不满足至少一个单元测试标准的测试。端到端测试是集成测试的一个子集;他们从最终用户的角度验证系统。端到端测试直接涉及应用程序使用的所有或几乎所有进程外依赖项。

  • An integration test is a test that doesn’t meet at least one of the criteria for a unit test. End-to-end tests are a subset of integration tests; they verify the system from the end user’s point of view. End-to-end tests reach out directly to all or almost all out-of-process dependencies your application works with.

  • 对于一本关于经典风格的经典书籍,我推荐 Kent Beck 的测试驱动开发:通过示例。有关伦敦风格的更多信息,请参阅Steve Freeman 和 Nat Pryce 合着的Growing Object-Oriented Software, Guided by Tests 。要进一步阅读有关使用依赖项的信息,我推荐Steven van Deursen 和 Mark Seemann 撰写的《依赖注入:原则、实践和模式》 。

  • For a canonical book about the classical style, I recommend Kent Beck’s Test-Driven Development: By Example. For more on the London style, see Growing Object-Oriented Software, Guided by Tests, by Steve Freeman and Nat Pryce. For further reading about working with dependencies, I recommend Dependency Injection: Principles, Practices, Patterns by Steven van Deursen and Mark Seemann.

3

3

单元测试的剖析

The anatomy of a unit test

本章涵盖:

This chapter covers:

  • 单元测试的结构

  • The structure of a unit test

  • 单元测试命名最佳实践

  • Unit test naming best practices

  • 使用参数化测试

  • Working with parameterized tests

  • 使用流畅的断言

  • Working with fluent assertions

在第 1 部分的剩余章节中,我将回顾一些基本主题。我将介绍典型单元测试的结构,它通常由安排、执行和断言(AAA) 模式表示。我还将展示我选择的单元测试框架 — xUnit — 并解释为什么我使用它而不是它的竞争对手之一。

In this remaining chapter of part 1, I’ll give you a refresher on some basic topics. I’ll go over the structure of a typical unit test, which is usually represented by the arrange, act, and assert (AAA) pattern. I’ll also show the unit testing framework of my choice — xUnit — and explain why I’m using it and not one of its competitors.

在此过程中,我们将讨论命名单元测试。关于这个主题有很多相互竞争的建议,不幸的是,他们中的大多数在改进单元测试方面做得不够好。在本章中,我将描述那些不太有用的命名做法,并说明为什么它们通常不是最佳选择。除了这些做法,我给你一个替代方案——一个简单、易于遵循的命名测试的指南,使它们不仅对编写它们的程序员而且对熟悉问题领域的任何其他人都可读.

Along the way, we’ll talk about naming unit tests. There are quite a few competing pieces of advice on this topic, and unfortunately, most of them don’t do a good enough job improving your unit tests. In this chapter, I describe those less-useful naming practices and show why they usually aren’t the best choice. Instead of those practices, I give you an alternative — a simple, easy-to-follow guideline for naming tests in a way that makes them readable not only to the programmer who wrote them, but also to any other person familiar with the problem domain.

最后,我将讨论有助于简化单元测试过程的框架的一些特性。不要担心此信息对 C# 和 .NET 过于具体;无论编程语言如何,大多数单元测试框架都表现出相似的功能。如果您学会了其中的一个,那么与另一个一起工作就不会有问题。

Finally, I’ll talk about some features of the framework that help streamline the process of unit testing. Don’t worry about this information being too specific to C# and .NET; most unit testing frameworks exhibit similar functionality, regardless of the programming language. If you learn one of them, you won’t have problems working with another.

3.1 如何构建单元测试

3.1  How to structure a unit test

本节介绍如何使用排列、操作和断言模式构建单元测试,应避免哪些陷阱,以及如何使测试尽可能可读。

This section shows how to structure unit tests using the arrange, act, and assert pattern, what pitfalls to avoid, and how to make your tests as readable as possible.

3.1.1 使用 AAA 模式

3.1.1  Using the AAA pattern

AAA 模式提倡将每个测试分为三个部分:arrangeactassert。(这种模式有时也称为3A 模式。)让我们使用一个Calculator计算两个数字之和的单一方法的类:

The AAA pattern advocates for splitting each test into three parts: arrange, act, and assert. (This pattern is sometimes also called the 3A pattern.) Let’s take a Calculator class with a single method that calculates a sum of two numbers:

公共课计算器
{
    public double Sum(double first, double second)
    {
        返回第一+第二;
    }
}
public class Calculator
{
    public double Sum(double first, double second)
    {
        return first + second;
    }
}

以下清单显示了验证类行为的测试。此测试遵循 AAA 模式。

The following listing shows a test that verifies the class’s behavior. This test follows the AAA pattern.

清单 3.1。Sum涵盖方法的测试Calculator

Listing 3.1. A test covering the Sum method in Calculator

公共课计算器测试   
{
    [事实]    
    public void Sum_of_two_numbers()   
    {
        // 安排
        双先= 10;                   
        双秒 = 20;                   
        var calculator = new Calculator();   

        // 行为
        double result = calculator.Sum(first, second);   

        //断言
        Assert.Equal(30, 结果);   
    }
}
public class CalculatorTests   
{
    [Fact]   
    public void Sum_of_two_numbers()   
    {
        // Arrange
        double first = 10;                   
        double second = 20;                  
        var calculator = new Calculator();   

        // Act
        double result = calculator.Sum(first, second);   

        // Assert
        Assert.Equal(30, result);   
    }
}

用于一组内聚测试的类容器

Class-container for a cohesive set of tests

xUnit 的属性指示测试

xUnit’s attribute indicating a test

单元测试名称

Name of the unit test

编排部分

Arrange section

行为部分

Act section

断言部分

Assert section

AAA 模式为套件中的所有测试提供了一个简单、统一的结构。这种统一性是这种模式的最大优势之一:一旦你习惯了它,你就可以轻松阅读和理解任何测试。这反过来又降低了整个测试套件的维护成本。结构如下:

The AAA pattern provides a simple, uniform structure for all tests in the suite. This uniformity is one of the biggest advantages of this pattern: once you get used to it, you can easily read and understand any test. That, in turn, reduces maintenance costs for your entire test suite. The structure is as follows:

  • 安排部分,您将被测系统 (SUT) 及其依赖项置于所需状态。

  • In the arrange section, you bring the system under test (SUT) and its dependencies to a desired state.

  • act 部分,您调用 SUT 上的方法,传递准备好的依赖项,并捕获输出值(如果有)。

  • In the act section, you call methods on the SUT, pass the prepared dependencies, and capture the output value (if any).

  • 断言部分,您验证结果。结果可能由返回值、SUT 及其合作者的最终状态或 SUT 调用这些合作者的方法来表示。

  • In the assert section, you verify the outcome. The outcome may be represented by the return value, the final state of the SUT and its collaborators, or the methods the SUT called on those collaborators.

Given-When-Then 模式

Given-When-Then pattern

您可能听说过Given-When-Then模式,它类似于 AAA。这种模式还提倡将测试分解为三个部分:

You might have heard of the Given-When-Then pattern, which is similar to AAA. This pattern also advocates for breaking the test down into three parts:

  • Given—— 对应于编排部分

  • Given — Corresponds to the arrange section

  • When—— 对应于act部分

  • When — Corresponds to the act section

  • Then—— 对应断言部分

  • Then — Corresponds to the assert section

就测试组成而言,这两种模式之间没有区别。唯一的区别是 Given-When-Then 结构对于非程序员来说更具可读性。因此,Given-When-Then 更适合与非技术人员共享的测试。

There’s no difference between the two patterns in terms of the test composition. The only distinction is that the Given-When-Then structure is more readable to non-programmers. Thus, Given-When-Then is more suitable for tests that are shared with non-technical people.

自然的倾向是开始用arrange部分编写测试。毕竟,它先于其他两个。这种方法在绝大多数情况下都适用,但从断言部分开始也是一个可行的选择。当您实践测试驱动开发 (TDD) 时——也就是说,当您在开发功能之前创建一个失败的测试时——您对功能的行为还不够了解。因此,首先勾勒出您对行为的期望,然后弄清楚如何开发系统来满足这种期望是有利的。

The natural inclination is to start writing a test with the arrange section. After all, it comes before the other two. This approach works well in the vast majority of cases, but starting with the assert section is a viable option too. When you practice Test-Driven Development (TDD) — that is, when you create a failing test before developing a feature  — you don’t know enough about the feature’s behavior yet. So, it becomes advantageous to first outline what you expect from the behavior and then figure out how to develop the system to meet this expectation.

这种技术可能看起来违反直觉,但它是我们解决问题的方式。我们首先考虑目标:特定行为应该为我们做什么。问题的实际解决是在那之后。在其他任何事情之前写下断言只是这种思维过程的形式化。但同样,这条准则仅适用于遵循 TDD 的情况——当您在生产代码之前编写测试时。如果您在测试之前编写生产代码,那么在您继续进行测试时,您已经知道行为会发生什么,因此从安排部分开始是更好的选择。

Such a technique may look counterintuitive, but it’s how we approach problem solving. We start by thinking about the objective: what a particular behavior should to do for us. The actual solving of the problem comes after that. Writing down the assertions before everything else is merely a formalization of this thinking process. But again, this guideline is only applicable when you follow TDD — when you write a test before the production code. If you write the production code before the test, by the time you move on to the test, you already know what to expect from the behavior, so starting with the arrange section is a better option.

3.1.2 避免多个 arrange、act 和 assert 部分

3.1.2  Avoid multiple arrange, act, and assert sections

有时,您可能会遇到包含多个arrangeactassert部分的测试。它通常如图3.1所示工作。

Occasionally, you may encounter a test with multiple arrange, act, or assert sections. It usually works as shown in figure 3.1.

图 3.1。多个 arrange、act 和 assert 部分暗示测试一次验证了太多东西。这样的测试需要拆分成几个测试来修复问题。

Figure 3.1. Multiple arrange, act, and assert sections are a hint that the test verifies too many things at once. Such a test needs to be split into several tests to fix the problem.

CH03 图 1 MultipleAAA

当您看到多个act部分被assert和可能的arrange部分分隔时,这意味着测试验证了多个行为单元。而且,正如我们在第 2 章中讨论的那样,这样的测试不再是单元测试,而是集成测试。最好避免这样的测试结构。单个操作可确保您的测试保持在单元测试范围内,这意味着它们简单、快速且易于理解。如果您看到包含一系列操作和断言的测试,请重构它。将每个行为提取到自己的测试中。

When you see multiple act sections separated by assert and, possibly, arrange sections, it means the test verifies multiple units of behavior. And, as we discussed in chapter 2, such a test is no longer a unit test but rather is an integration test. It’s best to avoid such a test structure. A single action ensures that your tests remain within the realm of unit testing, which means they are simple, fast, and easy to understand. If you see a test containing a sequence of actions and assertions, refactor it. Extract each act into a test of its own.

有时在集成测试中有多个act部分很好。您可能还记得上一章,集成测试可能很慢。加速它们的一种方法是将几个集成测试组合成一个具有多个行为断言的测试。当系统状态自然地从彼此流动时,它特别有用:也就是说,当一个动作同时作为后续动作的安排时。

It’s sometimes fine to have multiple act sections in integration tests. As you may remember from the previous chapter, integration tests can be slow. One way to speed them up is to group several integration tests together into a single test with multiple acts and assertions. It’s especially helpful when system states naturally flow from one another: that is, when an act simultaneously serves as an arrange for the subsequent act.

但是同样,这种优化技术只适用于集成测试——并不是所有的集成测试,而是那些已经很慢并且你不想变得更慢的测试。在足够快的单元测试或集成测试中不需要这样的优化。将多步单元测试拆分为多个测试总是更好。

But again, this optimization technique is only applicable to integration tests — and not all of them, but rather those that are already slow and that you don’t want to become even slower. There’s no need for such an optimization in unit tests or integration tests that are fast enough. It’s always better to split a multistep unit test into several tests.

3.1.3 避免在测试中使用 if 语句

3.1.3  Avoid if statements in tests

类似于arrangeactassert部分的多次出现,您有时可能会遇到带有语句的单元测试if。这也是一种反模式。测试——无论是单元测试还是集成测试——应该是没有分支的简单步骤序列。

Similar to multiple occurrences of the arrange, act, and assert sections, you may sometimes encounter a unit test with an if statement. This is also an anti-pattern. A test — whether a unit test or an integration test — should be a simple sequence of steps with no branching.

声明if表明该测试一次验证了太多的东西。因此,这样的测试应该分成几个测试。但与多个 AAA 部分的情况不同,集成测试也不例外。在测试中分支没有任何好处。您只会获得额外的维护成本:if语句使测试更难阅读和理解。

An if statement indicates that the test verifies too many things at once. Such a test, therefore, should be split into several tests. But unlike the situation with multiple AAA sections, there’s no exception for integration tests. There are no benefits in branching within a test. You only gain additional maintenance costs: if statements make the tests harder to read and understand.

3.1.4 每个部分应该有多大?

3.1.4  How large should each section be?

人们在开始使用 AAA 模式时常问的一个常见问题是,每个部分应该有多大?那么拆解部分——测试后清理的部分呢?关于每个测试部分的大小有不同的指导方针。

A common question people ask when starting out with the AAA pattern is, how large should each section be? And what about the teardown section — the section that cleans up after the test? There are different guidelines regarding the size for each of the test sections.

排列部分是最大的

The arrange section is the largest

编排部分通常是三个部分中最大的一个它可以与actassert部分的总和一样大。但如果它变得比这大得多,最好将安排提取到同一测试类中的私有方法中或提取到单独的工厂类中。两种流行的模式可以帮助您重用排列部分中的代码:Object MotherTest Data Builder

The arrange section is usually the largest of the three. It can be as large as the act and assert sections combined. But if it becomes significantly larger than that, it’s better to extract the arrangements either into private methods within the same test class or to a separate factory class. Two popular patterns can help you reuse the code in the arrange sections: Object Mother and Test Data Builder.

注意大于单行的行为部分

Watch out for act sections that are larger than a single line

act部分通常只是一行代码。如果该行为包含两行或更多行,则可能表示 SUT 的公共 API 存在问题。

The act section is normally just a single line of code. If the act consists of two or more lines, it could indicate a problem with the SUT’s public API.

最好用一个例子来表达这一点,所以让我们从第 2 章中取一个例子,我在下面的清单中重复了这一点。在此示例中,客户从商店购买商品。

It’s best to express this point with an example, so let’s take one from chapter 2, which I repeat in the following listing. In this example, the customer makes a purchase from a store.

清单 3.2。单行行为部分

Listing 3.2. A single-line act section

[事实]
public void Purchase_succeeds_when_enough_inventory()
{
    // 安排
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // 行为
    bool success = customer.Purchase(store, Product.Shampoo, 5);

    //断言
    断言。真(成功);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));
}
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(store, Product.Shampoo, 5);

    // Assert
    Assert.True(success);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

请注意,此测试中的act部分是单个方法调用,这是设计良好的类 API 的标志。现在将它与清单3.3中的版本进行比较:此act部分包含两行。这是 SUT 存在问题的迹象:它要求客户记住进行第二次方法调用以完成购买,因此缺乏封装。

Notice that the act section in this test is a single method call, which is a sign of a well-designed class’s API. Now compare it to the version in listing 3.3: this act section contains two lines. And that’s a sign of a problem with the SUT: it requires the client to remember to make the second method call to finish the purchase and thus lacks encapsulation.

清单 3.3。两行行为部分

Listing 3.3. A two-line act section

[事实]
public void Purchase_succeeds_when_enough_inventory()
{
    // 安排
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // 行为
    bool success = customer.Purchase(store, Product.Shampoo, 5);
    store.RemoveInventory(成功,Product.Shampoo,5);

    //断言
    断言。真(成功);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));
}
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    // Arrange
    var store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    var customer = new Customer();

    // Act
    bool success = customer.Purchase(store, Product.Shampoo, 5);
    store.RemoveInventory(success, Product.Shampoo, 5);

    // Assert
    Assert.True(success);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

以下是清单3.3act部分的内容:

Here’s what you can read from listing 3.3's act section:

  • 在第一行中,顾客试图从商店购买五件洗发水。

  • In the first line, the customer tries to acquire five units of shampoo from the store.

  • 在第二行中,库存从商店中移除。Purchase()仅当前面的调用返回成功时才会进行删除。

  • In the second line, the inventory is removed from the store. The removal takes place only if the preceding call to Purchase() returns a success.

新版本的问题是它需要两次方法调用才能执行单个操作。请注意,这不是测试本身的问题。该测试仍然验证相同的行为单元:进行购买的过程。问题在于类的 API 表面Customer。它不应该要求客户端进行额外的方法调用。

The issue with the new version is that it requires two method calls to perform a single operation. Note that this is not an issue with the test itself. The test still verifies the same unit of behavior: the process of making a purchase. The issue lies in the API surface of the Customer class. It shouldn’t require the client to make an additional method call.

从商业角度来看,一次成功的购买有两个结果:客户购买产品和减少商店库存。这两种结果必须一起实现,这意味着应该有一个公共方法来完成这两种事情。否则,如果客户端代码调用第一个方法而不调用第二个方法,则存在不一致的余地,在这种情况下,客户将获得产品,但商店中的可用数量不会减少。

From a business perspective, a successful purchase has two outcomes: the acquisition of a product by the customer and the reduction of the inventory in the store. Both of these outcomes must be achieved together, which means there should be a single public method that does both things. Otherwise, there’s a room for inconsistency if the client code calls the first method but not the second, in which case the customer will acquire the product but its available amount won’t be reduced in the store.

这种不一致称为不变违规。保护代码免受潜在不一致影响的行为称为封装。当不一致渗透到数据库中时,就会成为一个大问题:现在不可能通过简单地重新启动应用程序来重置它的状态。您将不得不处理数据库中损坏的数据,并且有可能联系客户并根据具体情况处理情况。试想一下,如果应用程序在没有实际保留库存的情况下生成确认收据,会发生什么情况。它可能会针对比您在不久的将来可能获得的库存更多的库存发出索赔,甚至收取费用。

Such an inconsistency is called an invariant violation. The act of protecting your code against potential inconsistencies is called encapsulation. When an inconsistency penetrates into the database, it becomes a big problem: now it’s impossible to reset the state of your application by simply restarting it. You’ll have to deal with the corrupted data in the database and, potentially, contact customers and handle the situation on a case-by-case basis. Just imagine what would happen if the application generated confirmation receipts without actually reserving the inventory. It might issue claims to, and even charge for, more inventory than you could feasibly acquire in the near future.

补救措施是始终保持代码封装。在前面的示例中,客户应该从商店中删除已获取的库存作为其Purchase方法的一部分,而不是依赖客户端代码来执行此操作。当谈到维护不变量时,您应该消除任何可能导致不变量违规的潜在行动方案。

The remedy is to maintain code encapsulation at all times. In the previous example, the customer should remove the acquired inventory from the store as part of its Purchase method and not rely on the client code to do so. When it comes to maintaining invariants, you should eliminate any potential course of action that could lead to an invariant violation.

将act部分简化为一行的准则适用于绝大多数包含业务逻辑的代码,但不适用于实用程序或基础结构代码。因此,我不会说“永远不要这样做”。不过,请务必检查每个此类案例是否存在潜在的封装漏洞。

This guideline of keeping the act section down to a single line holds true for the vast majority of code that contains business logic, but less so for utility or infrastructure code. Thus, I won’t say "never do it." Be sure to examine each such case for a potential breach in encapsulation, though.

断言部分应该包含多少个断言?

How many assertions should the assert section hold?

最后是断言部分。您可能听说过每次测试有一个断言的准则。它植根于前一章讨论的前提:以尽可能小的代码为目标的前提。

Finally, there’s the assert section. You may have heard about the guideline of having one assertion per test. It takes root in the premise discussed in the previous chapter: the premise of targeting the smallest piece of code possible.

如您所知,这个前提是不正确的。单元测试中的单元是行为单元而不是代码单元。单个行为单元可以表现出多种结果,可以在一次测试中对所有结果进行评估。

As you already know, this premise is incorrect. A unit in unit testing is a unit of behavior, not a unit of code. A single unit of behavior can exhibit multiple outcomes, and it’s fine to evaluate them all in one test.

话虽如此,您需要注意变得过大的断言部分:这可能是生产代码中缺少抽象的标志。例如,与其断言 SUT 返回的对象内的所有属性,不如在对象的类中定义适当的相等成员可能更好。然后,您可以使用单个断言将该对象与预期值进行比较。

Having that said, you need to watch out for assertion sections that grow too large: it could be a sign of a missing abstraction in the production code. For example, instead of asserting all properties inside an object returned by the SUT, it may be better to define proper equality members in the object’s class. You can then compare the object to an expected value using a single assertion.

拆解阶段呢?

What about the teardown phase?

有些人还区分了第四部分,拆解,它位于arrangeactassert之后。例如,您可以使用此部分删除测试创建的任何文件、关闭数据库连接等。拆解通常由一个单独的方法表示,该方法在类中的所有测试中重复使用。因此,我不将此阶段包括在 AAA 模式中。

Some people also distinguish a fourth section, teardown, which comes after arrange, act, and assert. For example, you can use this section to remove any files created by the test, close a database connection, and so on. The teardown is usually represented by a separate method, which is reused across all tests in the class. Thus, I don’t include this phase in the AAA pattern.

请注意,大多数单元测试不需要拆卸。单元测试不会与进程外依赖性对话,因此不会留下需要处理的副作用。那是集成测试的领域。我们将在第 3 部分中详细讨论如何在集成测试后正确清理。

Note that most unit tests don’t need teardown. Unit tests don’t talk to out-of-process dependencies and thus don’t leave side effects that need to be disposed of. That’s a realm of integration testing. We’ll talk more about how to properly clean up after integration tests in part 3.

3.1.5 区分被测系统

3.1.5  Differentiating the system under test

SUT 在测试中起着重要作用。它为您要在应用程序中调用的行为提供入口点。正如我们在前一章中讨论的那样,这种行为可以跨越多个类,也可以跨越一个方法。但是只能有一个入口点:一个触发该行为的类。

The SUT plays a significant role in tests. It provides an entry point for the behavior you want to invoke in the application. As we discussed in the previous chapter, this behavior can span across as many as several classes or as little as a single method. But there can be only one entry point: one class that triggers that behavior.

因此,将 SUT 与其依赖项区分开来非常重要,尤其是当存在相当多的依赖项时,这样您就不需要花费太多时间来弄清楚谁在测试中。为此,请始终在 tests 中命名 SUT sut。以下清单显示了CalculatorTests重命名实例后的样子Calculator

Thus it’s important to differentiate the SUT from its dependencies, especially when there are quite a few of them, so that you don’t need to spend too much time figuring out who is who in the test. To do that, always name the SUT in tests sut. The following listing shows how CalculatorTests would look after renaming the Calculator instance.

清单 3.4。将 SUT 与其依赖项区分开来

Listing 3.4. Differentiating the SUT from its dependencies

公共课计算器测试
{
    [事实]
    公共无效 Sum_of_two_numbers()
    {
        // 安排
        双先= 10;
        双秒 = 20;
        var sut = 新计算器();   

        // 行为
        double result = sut.Sum(first, second);

        //断言
        Assert.Equal(30, 结果);
    }
}
public class CalculatorTests
{
    [Fact]
    public void Sum_of_two_numbers()
    {
        // Arrange
        double first = 10;
        double second = 20;
        var sut = new Calculator();   

        // Act
        double result = sut.Sum(first, second);

        // Assert
        Assert.Equal(30, result);
    }
}

该计算器现在称为 sut。

The calculator is now called sut.

3.1.6 从测试中删除 arrange、act 和 assert 注释

3.1.6  Dropping the arrange, act, and assert comments from tests

正如将 SUT 与其依赖项区分开来很重要一样,将这三个部分彼此区分开来也很重要,这样您就不会花太多时间来弄清楚测试中的特定行属于哪个部分。一种方法是在每个部分的开头添加// Arrange// Act和注释。// Assert另一种方法是用空行分隔各个部分,如下所示。

Just as it’s important to set the SUT apart from its dependencies, it’s also important to differentiate the three sections from each other, so that you don’t spend too much time figuring out what section a particular line in the test belongs to. One way to do that is to put // Arrange, // Act, and // Assert comments before the beginning of each section. Another way is to separate the sections with empty lines, as shown next.

清单 3.5。各部分由空行分隔的计算器

Listing 3.5. Calculator with sections separated by empty lines

公共课计算器测试
{
    [事实]
    公共无效 Sum_of_two_numbers()
    {
        双先= 10;            
        双秒 = 20;            
        var sut = new 计算器();   

        double result = sut.Sum(first, second);   

        Assert.Equal(30, 结果);   
    }
}
public class CalculatorTests
{
    [Fact]
    public void Sum_of_two_numbers()
    {
        double first = 10;            
        double second = 20;           
        var sut = new Calculator();   

        double result = sut.Sum(first, second);   

        Assert.Equal(30, result);   
    }
}

安排

Arrange

行为

Act

断言

Assert

在大多数单元测试中,用空行分隔部分效果很好。它允许您在简洁性和可读性之间保持平衡。但是,它在大型测试中效果不佳,您可能希望在arrange部分中放置额外的空行以区分配置阶段。这在集成测试中很常见——它们通常包含复杂的设置逻辑。所以,

Separating sections with empty lines works great in most unit tests. It allows you to keep a balance between brevity and readability. It doesn’t work as well in large tests, though, where you may want to put additional empty lines inside the arrange section to differentiate between configuration stages. This is often the case in integration tests — they frequently contain complicated setup logic. Therefore,

  • 在遵循 AAA 模式的测试中删除部分注释,您可以在其中避免安排断言部分内的额外空行。

  • Drop the section comments in tests that follow the AAA pattern and where you can avoid additional empty lines inside the arrange and assert sections.

  • 否则保留部分注释。

  • Keep the section comments otherwise.

3.2 探索xUnit测试框架

3.2  Exploring the xUnit testing framework

在本节中,我简要概述了 .NET 中可用的单元测试工具及其功能。我使用 xUnit ( https://github.com/xunit/xunit ) 作为单元测试框架(请注意,您需要安装xunit.runner.visualstudioNuGet 包才能从 Visual Studio 运行 xUnit 测试)。尽管此框架仅适用于 .NET,但每种面向对象的语言(Java、C++、JavaScript 等)都有单元测试框架,而且所有这些框架看起来都非常相似。如果您与其中一个合作过,那么与另一个合作就不会有任何问题。

In this section, I give a brief overview of unit testing tools available in .NET, and their features. I’m using xUnit (https://github.com/xunit/xunit) as the unit testing framework (note that you need to install the xunit.runner.visualstudio NuGet package in order to run xUnit tests from Visual Studio). Although this framework works in .NET only, every object-oriented language (Java, C++, JavaScript, and so on) has unit testing frameworks, and all those frameworks look quite similar to each other. If you’ve worked with one of them, you won’t have any issues working with another.

仅在 .NET 中,就有多种选择可供选择,例如 NUnit ( https://github.com/nunit/nunit ) 和内置的 Microsoft MSTest。我个人更喜欢 xUnit,原因我稍后会描述,但您也可以使用 NUnit;这两个框架在功能方面几乎不相上下。不过,我不推荐 MSTest;它不提供与 xUnit 和 NUnit 相同级别的灵活性。不要相信我的话——即使是 Microsoft 内部的人也不使用 MSTest。例如,ASP.NET Core 团队使用 xUnit。

In .NET alone, there are several alternatives to choose from, such as NUnit (https://github.com/nunit/nunit) and the built-in Microsoft MSTest. I personally prefer xUnit for the reasons I’ll describe shortly, but you can also use NUnit; these two frameworks are pretty much on par in terms of functionality. I don’t recommend MSTest, though; it doesn’t provide the same level of flexibility as xUnit and NUnit. And don’t take my word for it — even people inside Microsoft refrain from using MSTest. For example, the ASP.NET Core team uses xUnit.

我更喜欢 xUnit,因为它是更简洁的 NUnit 版本。例如,您可能已经注意到,在我目前提出的测试中,除了 之外没有与框架相关的属性[Fact],这将方法标记为单元测试,因此单元测试框架知道要运行它。没有[TestFixture]属性:任何公共类都可以包含单元测试。也没有[SetUp][TearDown]。如果需要在测试之间共享配置逻辑,可以将其放在构造函数中。如果您需要清理一些东西,您可以实现该IDisposable接口,如清单所示。

I prefer xUnit because it’s a cleaner, more concise version of NUnit. For example, you may have noticed that in the tests I’ve brought up so far, there are no framework-related attributes other than [Fact], which marks the method as a unit test so the unit testing framework knows to run it. There are no [TestFixture] attributes: any public class can contain a unit test. There’s also no [SetUp] or [TearDown]. If you need to share configuration logic between tests, you can put it inside the constructor. And if you need to clean something up, you can implement the IDisposable interface, as shown in this listing.

清单 3.6。所有测试共享的安排和拆卸逻辑

Listing 3.6. Arrangement and teardown logic, shared by all tests

公共类 CalculatorTests:IDisposable
{
    私人只读计算器_sut;

    公共计算器测试()        
    {                               
        _sut = 新计算器();    
    }                              

    [事实]
    公共无效 Sum_of_two_numbers()
    {
        /* ... */
    }

    公共无效处置()    
    {                        
        _sut.CleanUp();      
    }                        
}
public class CalculatorTests : IDisposable
{
    private readonly Calculator _sut;

    public CalculatorTests()       
    {                              
        _sut = new Calculator();   
    }                              

    [Fact]
    public void Sum_of_two_numbers()
    {
        /* ... */
    }

    public void Dispose()   
    {                       
        _sut.CleanUp();     
    }                       
}

在课堂上的每个测试之前调用

Called before each test in the class

在课堂上的每个测试后调用

Called after each test in the class

如您所见,xUnit 作者在简化框架方面采取了重要步骤。许多以前需要额外配置(如[TestFixture][SetUp]属性)的概念现在依赖于约定或内置语言结构。

As you can see, the xUnit authors took significant steps toward simplifying the framework. A lot of notions that previously required additional configuration (like [TestFixture] or [SetUp] attributes) now rely on conventions or built-in language constructs.

我特别喜欢这个[Fact]属性,特别是因为它叫Factand not Test。它强调了我在上一章中提到的经验法则:每个测试都应该讲述一个故事。这个故事是关于问题域的一个单独的、原子的场景或事实,通过的测试证明这个场景或事实是正确的。如果测试失败,则意味着故事不再有效并且您需要重写它,或者系统本身必须修复。

I particularly like the [Fact] attribute, specifically because it’s called Fact and not Test. It emphasizes the rule of thumb I mentioned in the previous chapter: each test should tell a story. This story is an individual, atomic scenario or fact about the problem domain, and the passing test is a proof that this scenario or fact holds true. If the test fails, it means either the story is no longer valid and you need to rewrite it, or the system itself has to be fixed.

我鼓励您在编写单元测试时采用这种思维方式。您的测试不应该是对生产代码所做的事情的沉闷枚举。相反,它们应该提供应用程序行为的更高级别的描述。理想情况下,这种描述应该不仅对程序员有意义,而且对业务人员也有意义。

I encourage you to adopt this way of thinking when you write unit tests. Your tests shouldn’t be a dull enumeration of what the production code does. Rather, they should provide a higher-level description of the application’s behavior. Ideally, this description should be meaningful not just to programmers but also to business people.

3.3 在测试之间重用测试夹具

3.3  Reusing test fixtures between tests

了解如何以及何时在测试之间重用代码很重要。在排列部分之间重用代码是缩短和简化测试的好方法,本节将展示如何正确地做到这一点。

It’s important to know how and when to reuse code between tests. Reusing code between arrange sections is a good way to shorten and simplify your tests, and this section shows how to do that properly.

我之前提到过,夹具布置通常会占用太多空间。将这些安排提取到单独的方法或类中是有意义的,然后您可以在测试之间重用这些方法或类。有两种方法可以执行这种重用,但只有一种是有益的;另一个导致维护成本增加。

I mentioned earlier that often, fixture arrangements take up too much space. It makes sense to extract these arrangements into separate methods or classes that you then reuse between tests. There are two ways you can perform such reuse, but only one of them is beneficial; the other leads to increased maintenance costs.

测试治具

Test fixture

术语测试夹具有两个常见用途:

The term test fixture has two common uses:

  1. 测试夹具是测试运行所针对的对象。该对象可以是常规依赖项——传递给 SUT 的参数。也可以是数据库中的数据,也可以是硬盘上的文件。这样的对象需要在每次测试运行之前保持已知的固定状态,因此它会产生相同的结果。因此这个词fixture

  2. A test fixture is an object the test runs against. This object can be a regular dependency — an argument that is passed to the SUT. It can also be data in the database or a file on the hard disk. Such an object needs to remain in a known, fixed state before each test run, so it produces the same result. Hence the word fixture.

  3. 另一个定义来自 NUnit 测试框架。在 NUnit 中,TestFixture是标记包含测试的类的属性。

  4. The other definition comes from the NUnit testing framework. In NUnit, TestFixture is an attribute that marks a class containing tests.

我在整本书中使用第一个定义。

I use the first definition throughout this book.

第一种(不正确的)重用测试装置的方法是在测试的构造函数中初始化它们(如果您使用的是 NUnit,则使用属性标记的方法[SetUp]),如下所示。

The first — incorrect — way to reuse test fixtures is to initialize them in the test’s constructor (or the method marked with a [SetUp] attribute if you are using NUnit), as shown next.

清单 3.7。将初始化代码提取到测试构造函数中

Listing 3.7. Extracting the initialization code into the test constructor

公开课 CustomerTests
{
    私人只读商店_store;                  
    私人只读客户_sut;

    公共客户测试()                           
    {                                                
        _store = new 商店();                        
        _store.AddInventory(Product.Shampoo, 10);    
        _sut = 新客户();                       
    }                                               

    [事实]
    public void Purchase_succeeds_when_enough_inventory()
    {
        bool success = _sut.Purchase(_store, Product.Shampoo, 5);

        断言。真(成功);
        Assert.Equal(5, _store.GetInventory(Product.Shampoo));
    }

    [事实]
    public void Purchase_fails_when_not_enough_inventory()
    {
        bool success = _sut.Purchase(_store, Product.Shampoo, 15);

        断言.False(成功);
        Assert.Equal(10, _store.GetInventory(Product.Shampoo));
    }
}
public class CustomerTests
{
    private readonly Store _store;                  
    private readonly Customer _sut;

    public CustomerTests()                          
    {                                               
        _store = new Store();                       
        _store.AddInventory(Product.Shampoo, 10);   
        _sut = new Customer();                      
    }                                               

    [Fact]
    public void Purchase_succeeds_when_enough_inventory()
    {
        bool success = _sut.Purchase(_store, Product.Shampoo, 5);

        Assert.True(success);
        Assert.Equal(5, _store.GetInventory(Product.Shampoo));
    }

    [Fact]
    public void Purchase_fails_when_not_enough_inventory()
    {
        bool success = _sut.Purchase(_store, Product.Shampoo, 15);

        Assert.False(success);
        Assert.Equal(10, _store.GetInventory(Product.Shampoo));
    }
}

普通测试治具

Common test fixture

在课堂上的每个测试之前运行

Runs before each test in the class

清单3.7中的两个测试具有共同的配置逻辑。事实上,它们的排列部分是相同的,因此可以完全提取到CustomerTests的构造函数中——这正是我在这里所做的。测试本身不再包含安排。

The two tests in listing 3.7 have common configuration logic. In fact, their arrange sections are the same and thus can be fully extracted into CustomerTests's constructor — which is precisely what I did here. The tests themselves no longer contain arrangements.

使用这种方法,您可以显着减少测试代码的数量——您可以在测试中摆脱大部分甚至所有的测试夹具配置。但是这种技术有两个明显的缺点:

With this approach, you can significantly reduce the amount of test code — you can get rid of most or even all test fixture configurations in tests. But this technique has two significant drawbacks:

  • 它在测试之间引入了高耦合。

  • It introduces high coupling between tests.

  • 它降低了测试的可读性。

  • It diminishes test readability.

让我们更详细地讨论这些缺点。

Let’s discuss these drawbacks in more detail.

3.3.1 测试之间的高耦合是一种反模式

3.3.1  High coupling between tests is an anti-pattern

在新版本中,如清单3.7所示,所有测试都相互耦合:修改一个测试的排列逻辑将影响该类中的所有测试。例如,更改此行

In the new version, shown in listing 3.7, all tests are coupled to each other: a modification of one test’s arrangement logic will affect all tests in the class. For example, changing this line

_store.AddInventory(Product.Shampoo, 10);
_store.AddInventory(Product.Shampoo, 10);

对此

to this

_store.AddInventory(Product.Shampoo, 15 );
_store.AddInventory(Product.Shampoo, 15);

会使测试对商店初始状态的假设无效,因此会导致不必要的测试失败。

would invalidate the assumption the tests make about the store’s initial state and therefore would lead to unnecessary test failures.

这违反了一条重要的准则:对一个测试的修改不应影响其他测试。该指南类似于我们在第 2 章中讨论的内容——测试应该彼此隔离运行。但这不一样。在这里,我们说的是独立修改测试,而不是独立执行。两者都是精心设计的测试的重要属性。

That’s a violation of an important guideline: a modification of one test should not affect other tests. This guideline is similar to what we discussed in chapter 2 — that tests should run in isolation from each other. It’s not the same, though. Here, we are talking about independent modification of tests, not independent execution. Both are important attributes of a well-designed test.

要遵循此准则,您需要避免在测试类中引入共享状态。这两个私有字段是这种状态的示例:

To follow this guideline, you need to avoid introducing shared state in test classes. These two private fields are examples of such state:

私人只读商店_store;
私人只读客户_sut;
private readonly Store _store;
private readonly Customer _sut;

3.3.2 在测试中使用构造函数降低了测试的可读性

3.3.2  The use of constructors in tests diminishes test readability

将安排代码提取到构造函数中的另一个缺点是降低了测试可读性。您不再仅通过查看测试本身就可以看到全貌。您必须检查类中的不同位置以了解测试方法的作用。

The other drawback to extracting the arrangement code into the constructor is diminished test readability. You no longer see the full picture just by looking at the test itself. You have to examine different places in the class to understand what the test method does.

即使没有太多的排列逻辑——比如说,只有 fixture 的实例化——你仍然最好将它直接移动到测试方法。否则,您会想知道它是否真的只是实例化,还是那里也配置了其他东西。一个独立的测试不会给你留下这样的不确定性。

Even if there’s not much arrangement logic — say, only instantiation of the fixtures — you are still better off moving it directly to the test method. Otherwise, you’ll wonder if it’s really just instantiation or something else is being configured there, too. A self-contained test doesn’t leave you with such uncertainties.

3.3.3 重用测试夹具的更好方法

3.3.3  A better way to reuse test fixtures

在重用测试装置时,使用构造函数并不是最好的方法。第二种方法——有益的方法——是在测试类中引入私有工厂方法,如以下清单所示。

The use of the constructor is not the best approach when it comes to reusing test fixtures. The second way — the beneficial one — is to introduce private factory methods in the test class, as shown in the following listing.

清单 3.8。公共初始化代码被提取到私有工厂方法中。

Listing 3.8. The common initialization code is extracted into private factory methods.

公开课 CustomerTests
{
    [事实]
    public void Purchase_succeeds_when_enough_inventory()
    {
        Store store = CreateStoreWithInventory(Product.Shampoo, 10);
        客户 sut = CreateCustomer();

        bool success = sut.Purchase(store, Product.Shampoo, 5);

        断言。真(成功);
        Assert.Equal(5, store.GetInventory(Product.Shampoo));
    }

    [事实]
    public void Purchase_fails_when_not_enough_inventory()
    {
        Store store = CreateStoreWithInventory(Product.Shampoo, 10);
        客户 sut = CreateCustomer();

        bool success = sut.Purchase(store, Product.Shampoo, 15);

        断言.False(成功);
        Assert.Equal(10, store.GetInventory(Product.Shampoo));
    }

    私人商店 CreateStoreWithInventory(
        Product 产品, int 数量)
    {
        商店商店=新商店();
        store.AddInventory(产品,数量);
        退货商店;
    }

    私有静态客户 CreateCustomer()
    {
        返回新客户();
    }
}
public class CustomerTests
{
    [Fact]
    public void Purchase_succeeds_when_enough_inventory()
    {
        Store store = CreateStoreWithInventory(Product.Shampoo, 10);
        Customer sut = CreateCustomer();

        bool success = sut.Purchase(store, Product.Shampoo, 5);

        Assert.True(success);
        Assert.Equal(5, store.GetInventory(Product.Shampoo));
    }

    [Fact]
    public void Purchase_fails_when_not_enough_inventory()
    {
        Store store = CreateStoreWithInventory(Product.Shampoo, 10);
        Customer sut = CreateCustomer();

        bool success = sut.Purchase(store, Product.Shampoo, 15);

        Assert.False(success);
        Assert.Equal(10, store.GetInventory(Product.Shampoo));
    }

    private Store CreateStoreWithInventory(
        Product product, int quantity)
    {
        Store store = new Store();
        store.AddInventory(product, quantity);
        return store;
    }

    private static Customer CreateCustomer()
    {
        return new Customer();
    }
}

通过将公共初始化代码提取到私有工厂方法中,您还可以缩短测试代码,但同时保留测试中发生的事情的完整上下文。此外,只要您使私有方法足够通用,它们就不会相互耦合测试。也就是说,允许测试指定他们希望如何创建固定装置。

By extracting the common initialization code into private factory methods, you can also shorten the test code, but at the same time keep the full context of what’s going on in the tests. Moreover, the private methods don’t couple tests to each other as long as you make them generic enough. That is, allow the tests specify how they want the fixtures be created.

看看这一行,例如:

Look at this line, for example:

Store store = CreateStoreWithInventory(Product.Shampoo, 10);
Store store = CreateStoreWithInventory(Product.Shampoo, 10);

该测试明确指出它希望工厂方法向商店添加 10 单位洗发水。这是高度可读和可重用的。它具有可读性,因为您无需检查工厂方法的内部结构即可了解所创建商店的属性。它是可重用的,因为您也可以在其他测试中使用此方法。

The test explicitly states that it wants the factory method to add 10 units of shampoo to the store. This is both highly readable and reusable. It’s readable because you don’t need to examine the internals of the factory method to understand the attributes of the created store. It’s reusable because you can utilize this method in other tests, too.

请注意,在这个特定的示例中,不需要引入工厂方法,因为排列逻辑非常简单。仅将其视为演示。

Note that in this particular example, there’s no need to introduce factory methods, as the arrangement logic is quite simple. View it merely as a demonstration.

重用测试夹具这一规则有一个例外。如果所有或几乎所有测试都使用了夹具,则可以在构造函数中实例化夹具。使用数据库的集成测试通常就是这种情况。所有这些测试都需要一个数据库连接,您可以初始化一次,然后在任何地方重复使用。但即便如此,引入基类并在该类的构造函数中初始化数据库连接更有意义,而不是在单独的测试类中。

There’s one exception to this rule of reusing test fixtures. You can instantiate a fixture in the constructor if it’s used by all or almost all tests. This is often the case for integration tests that work with a database. All such tests require a database connection, which you can initialize once and then reuse everywhere. But even then, it would make more sense to introduce a base class and initialize the database connection in that class’s constructor, not in individual test classes.

清单 3.9。基类中的公共初始化代码

Listing 3.9. Common initialization code in a base class

公共类客户测试:集成测试
{
    [事实]
    public void Purchase_succeeds_when_enough_inventory()
    {
        /* 在这里使用_database */
    }
}

公共抽象类集成测试:IDisposable
{
    受保护的只读数据库_database;

    受保护的 IntegrationTests()
    {
        _database = 新数据库();
    }

    公共无效处置()
    {
        _database.Dispose();
    }
}
public class CustomerTests : IntegrationTests
{
    [Fact]
    public void Purchase_succeeds_when_enough_inventory()
    {
        /* use _database here */
    }
}

public abstract class IntegrationTests : IDisposable
{
    protected readonly Database _database;

    protected IntegrationTests()
    {
        _database = new Database();
    }

    public void Dispose()
    {
        _database.Dispose();
    }
}

注意如何CustomerTests保持无构造函数。_database它通过从基类继承来访问实例IntegrationTests

Notice how CustomerTests remains constructor-less. It gets access to the _database instance by inheriting from the IntegrationTests base class.

3.4 命名单元测试

3.4  Naming a unit test

为您的测试提供富有表现力的名称很重要。正确的命名有助于您了解测试验证的内容以及底层系统的行为方式。

It’s important to give expressive names to your tests. Proper naming helps you understand what the test verifies and how the underlying system behaves.

那么,您应该如何命名单元测试呢?在过去的十年里,我见过并尝试过很多命名约定。最突出但可能最无用的约定之一是以下约定:

So, how should you name a unit test? I’ve seen and tried a lot of naming conventions over the past decade. One of the most prominent, and probably least helpful, is the following convention:

[MethodUnderTest]_[Scenario]_[ExpectedResult]

[MethodUnderTest]_[Scenario]_[ExpectedResult]

在哪里

where

  • MethodUnderTest是您正在测试的方法的名称。

  • MethodUnderTest is the name of the method you are testing.

  • Scenario是测试方法的条件。

  • Scenario is the condition under which you test the method.

  • ExpectedResult是您期望被测方法在当前场景中执行的操作。

  • ExpectedResult is what you expect the method under test to do in the current scenario.

它特别无用,因为它鼓励您专注于实现细节而不是行为。

It’s unhelpful specifically because it encourages you to focus on implementation details instead of the behavior.

通俗易懂的英语中的简单短语做得更好:它们更具表现力,不会让您陷入僵化的命名结构中。使用简单的短语,您可以以对客户或领域专家有意义的方式描述系统行为。给你一个简单的英语标题测试的例子,这里是清单3.5中的测试:

Simple phrases in plain English do a much better job: they are more expressive and don’t box you in a rigid naming structure. With simple phrases, you can describe the system behavior in a way that’s meaningful to a customer or a domain expert. To give you an example of a test titled in plain English, here’s the test from listing 3.5 once again:

公共课计算器测试
{
    [事实]
    公共无效 Sum_of_two_numbers()
    {
        双先= 10;
        双秒 = 20;
        var sut = 新计算器();

        double result = sut.Sum(first, second);

        Assert.Equal(30, 结果);
    }
}
public class CalculatorTests
{
    [Fact]
    public void Sum_of_two_numbers()
    {
        double first = 10;
        double second = 20;
        var sut = new Calculator();

        double result = sut.Sum(first, second);

        Assert.Equal(30, result);
    }
}

Sum_of_two_numbers如何使用约定重写测试的名称 ( ) [MethodUnderTest]_[Scenario]_[ExpectedResult]?大概是这样的:

How could the test’s name (Sum_of_two_numbers) be rewritten using the [MethodUnderTest]_[Scenario]_[ExpectedResult] convention? Probably something like this:

公共无效 Sum_TwoNumbers_ReturnsSum()
public void Sum_TwoNumbers_ReturnsSum()

被测方法是Sum,场景包括两个数字,预期结果是这两个数字的总和。新名称在程序员眼中看起来合乎逻辑,但它真的有助于提高测试的可读性吗?一点也不。对于不知情的人来说,这是希腊语。想一想:为什么Sum在测试名称中出现两次?这个Returns措辞是什么意思?款项返还到哪里?你不可能知道。

The method under test is Sum, the scenario includes two numbers, and the expected result is a sum of those two numbers. The new name looks logical to a programmer’s eye, but does it really help with test readability? Not at all. It’s Greek to an uninformed person. Think about it: why does Sum appear twice in the name of the test? And what is this Returns phrasing all about? Where is the sum returned to? You can’t know.

有些人可能会争辩说,非程序员对这个名字的看法并不重要。毕竟,单元测试是程序员为程序员而不是领域专家编写的。程序员擅长破译神秘的名字——这是他们的工作!

Some might argue that it doesn’t really matter what a non-programmer would think of this name. After all, unit tests are written by programmers for programmers, not domain experts. And programmers are good at deciphering cryptic names — it’s their job!

这是事实,但只是在一定程度上。神秘的名字对每个人都造成了认知负担,无论是否是程序员。他们需要额外的脑容量来弄清楚测试究竟验证了什么以及它与业务需求的关系。这可能看起来并不多,但随着时间的推移,精神负担会增加。它缓慢但肯定会增加整个测试套件的维护成本。如果您在忘记特性的细节或试图理解同事编写的测试后返回测试,这一点尤其值得注意。阅读别人的代码已经够难的了——任何帮助理解它的方法都是非常有用的。

This is true, but only to a degree. Cryptic names impose a cognitive tax on everyone, programmers or not. They require additional brain capacity to figure out what exactly the test verifies and how it relates to business requirements. This may not seem like much, but the mental burden adds up over time. It slowly but surely increases the maintenance cost for the entire test suite. It’s especially noticeable if you return to the test after you’ve forgotten about the feature’s specifics, or try to understand a test written by a colleague. Reading someone else’s code is already difficult enough — any help understanding it is of considerable use.

这里又是两个版本:

Here are the two versions again:

公共无效 Sum_of_two_numbers()
公共无效 Sum_TwoNumbers_ReturnsSum()
public void Sum_of_two_numbers()
public void Sum_TwoNumbers_ReturnsSum()

用简单的英语写的初始名称更容易阅读。它是对被测行为的脚踏实地的描述。

The initial name written in plain English is much simpler to read. It is a down-to-earth description of the behavior under test.

3.4.1 单元测试命名指南

3.4.1 Unit test naming guidelines

遵循以下准则来编写富有表现力、易于阅读的测试名称:

Adhere to the following guidelines to write expressive, easily readable test names:

  • 不要遵循严格的命名策略。您根本无法将复杂行为的高级描述放入此类策略的狭窄框内。允许言论自由。

  • Don’t follow a rigid naming policy. You simply can’t fit a high-level description of a complex behavior into the narrow box of such a policy. Allow freedom of expression.

  • 为测试命名,就好像您正在向熟悉问题域的非程序员描述场景一样。领域专家或业务分析师就是一个很好的例子。

  • Name the test as if you were describing the scenario to a non-programmer who is familiar with the problem domain. A domain expert or a business analyst is a good example.

  • 用下划线分隔单词。这样做有助于提高可读性,尤其是在长名称中。

  • Separate words with underscores. Doing so helps improve readability, especially in long names.

请注意,我在命名测试类时没有使用下划线,CalculatorTests. 类的名字通常不会那么长,所以没有下划线也能读起来很好。

Notice that I didn’t use underscores when naming the test class, CalculatorTests. Classes' names normally are not as long, so they read fine without underscores.

另请注意,虽然我在命名测试类时使用了该模式[ClassName]Tests,但这并不意味着测试仅限于验证ClassName. 请记住,单元测试中的单元行为单元,而不是类。这个单元可以跨越一个或几个班级;实际大小无关紧要。不过,你必须从某个地方开始。将类视为[ClassName]Tests:一个入口点,一个 API,您可以使用它来验证一个行为单元。

Also notice that although I use the pattern [ClassName]Tests when naming test classes, it doesn’t mean the tests are limited to verifying only that ClassName. Remember, the unit in unit testing is a unit of behavior, not a class. This unit can span across one or several classes; the actual size is irrelevant. Still, you have to start somewhere. View the class in [ClassName]Tests as just that: an entry point, an API, using which you can verify a unit of behavior.

3.4.2 示例:根据指南重命名测试

3.4.2  Example: Renaming a test toward the guidelines

让我们以一个测试为例,并尝试使用我刚才概述的指南逐步改进它的名称。在下面的清单中,您可以看到一个测试,用于验证具有过去日期的交付是否无效。测试的名称是使用不利于测试可读性的严格命名策略编写的。

Let’s take a test as an example and try to gradually improve its name using the guidelines I just outlined. In the following listing, you can see a test verifying that a delivery with a past date is invalid. The test’s name is written using the rigid naming policy that doesn’t help with the test readability.

清单 3.10。使用严格命名策略命名的测试

Listing 3.10. A test named using the rigid naming policy

[事实]
public void IsDeliveryValid_InvalidDate_ReturnsFalse()
{
    DeliveryService sut = new DeliveryService();
    日期时间 pastDate = DateTime.Now.AddDays(-1);
    交货交货=新交货
    {
        日期 = 过去日期
    };

    bool isValid = sut.IsDeliveryValid(交付);

    断言.False(isValid);
}
[Fact]
public void IsDeliveryValid_InvalidDate_ReturnsFalse()
{
    DeliveryService sut = new DeliveryService();
    DateTime pastDate = DateTime.Now.AddDays(-1);
    Delivery delivery = new Delivery
    {
        Date = pastDate
    };

    bool isValid = sut.IsDeliveryValid(delivery);

    Assert.False(isValid);
}

此测试检查是否DeliveryService正确地将日期不正确的交付标识为无效。您将如何用简单的英语重写测试名称?以下将是一个很好的第一次尝试:

This test checks that DeliveryService properly identifies a delivery with an incorrect date as invalid. How would you rewrite the test’s name in plain English? The following would be a good first try:

public void Delivery_with_invalid_date_should_be_considered_invalid()
public void Delivery_with_invalid_date_should_be_considered_invalid()

在新版本中注意两件事:

Notice two things in the new version:

  • 这个名字现在对非程序员来说很有意义,这意味着程序员也将更容易理解它。

  • The name now makes sense to a non-programmer, which means programmers will have an easier time understanding it, too.

  • SUT 方法的名称—— IsDeliveryValid 不再是测试名称的一部分。

  • The name of the SUT’s method — IsDeliveryValid — is no longer part of the test’s name.

第二点是用简单的英语重写测试名称的自然结果,因此很容易被忽略。然而,这个结果很重要,可以提升为自己的指导方针。

The second point is a natural consequence of rewriting the test’s name in plain English and thus can be easily overlooked. However, this consequence is important and can be elevated into a guideline of its own.

测试名称中的被测方法

Method under test in the test’s name

 

 

[警告] 警告

不要在测试名称中包含 SUT 方法的名称。

Don’t include the name of the SUT’s method in the test’s name.

请记住,您测试的不是代码,而是应用程序的行为。因此,被测方法的名称是什么并不重要。正如我之前提到的,SUT 只是一个入口点:一种调用行为的方法。您可以决定将被测方法重命名为,比方说,IsDeliveryCorrect它不会影响 SUT 的行为。另一方面,如果您遵循原始命名约定,则必须重命名测试。这再次表明,目标代码而不是行为将测试耦合到该代码的实现细节,这对测试套件的可维护性产生了负面影响。在第 5 章中更多地讨论这个问题。

Remember, you don’t test code, you test application behavior. Therefore, it doesn’t matter what the name of the method under test is. As I mentioned previously, the SUT is just an entry point: a means to invoke a behavior. You can decide to rename the method under test to, say, IsDeliveryCorrect, and it will have no effect on the SUT’s behavior. On the other hand, if you follow the original naming convention, you’ll have to rename the test. This once again shows that targeting code instead of behavior couples tests to that code’s implementation details, which negatively affects the test suite’s maintainability. More on this issue in chapter 5.

该准则的唯一例外是您处理实用程序代码时。这样的代码不包含业务逻辑——它的行为并没有超出简单的辅助功能,因此对业务人员没有任何意义。可以在那里使用 SUT 的方法名称。

The only exception to this guideline is when you work on utility code. Such code doesn’t contain business logic — its behavior doesn’t go much beyond simple auxiliary functionality and thus doesn’t mean anything to business people. It’s fine to use the SUT’s method names there.

但是让我们回到这个例子。新版本的测试名称是一个好的开始,但还可以进一步改进。交货日期无效究竟意味着什么?从清单3.10中的测试中,我们可以看到无效日期是过去的任何日期。这是有道理的——你应该只被允许选择未来的交货日期。

But let’s get back to the example. The new version of the test’s name is a good start, but it can be improved further. What does it mean for a delivery date to be invalid, exactly? From the test in listing 3.10, we can see that an invalid date is any date in the past. This makes sense — you should only be allowed to choose a delivery date in the future.

因此,让我们具体一点,并在测试名称中反映这些知识:

So let’s be specific and reflect this knowledge in the test’s name:

public void Delivery_with_past_date_should_be_considered_invalid()
public void Delivery_with_past_date_should_be_considered_invalid()

这更好但仍然不理想。太冗长了。considered我们可以在不失去任何意义的情况下去掉这个词:

This is better but still not ideal. It’s too verbose. We can get rid of the word considered without any loss of meaning:

public void Delivery_with_past_date_should_be_invalid()
public void Delivery_with_past_date_should_be_invalid()

措辞should be是另一种常见的反模式。在本章的前面,我提到过测试是关于行为单元的单个原子事实。陈述事实时,没有愿望或愿望的余地。相应地命名测试——替换应该

The wording should be is another common anti-pattern. Earlier in this chapter I mentioned that a test is a single, atomic fact about a unit of behavior. There’s no place for a wish or a desire when stating a fact. Name the test accordingly — replace should be with is:

public void Delivery_with_past_date_is_invalid()
public void Delivery_with_past_date_is_invalid()

最后,无需回避基本的英语语法。文章帮助测试完美阅读。将文章a添加到测试的名称中:

And finally, there’s no need to avoid basic English grammar. Articles help the test read flawlessly. Add the article a to the test’s name:

public void Delivery_with_a_past_date_is_invalid()
public void Delivery_with_a_past_date_is_invalid()

给你。这个最终版本是对一个事实的直截了当的陈述,它本身描述了被测应用程序行为的一个方面:在这种特殊情况下,确定是否可以完成交付的方面。

There you go. This final version is a straight-to-the-point statement of a fact, which itself describes one of the aspects of the application behavior under test: in this particular case, the aspect of determining whether a delivery can be done.

3.5 重构参数化测试

3.5  Refactoring to parameterized tests

一个测试通常不足以完全描述一个行为单元。这样的单元通常由多个组件组成,每个组件都应该通过自己的测试来捕获。如果行为足够复杂,描述它的测试数量可能会急剧增加,并且可能变得难以管理。幸运的是,大多数单元测试框架都提供允许您使用参数化测试对类似测试进行分组的功能(见图3.2)。在本节中,我将首先展示由单独测试描述的每个此类行为组件,然后演示如何将这些测试组合在一起。

One test usually is not enough to fully describe a unit of behavior. Such a unit normally consists of multiple components, each of which should be captured with its own test. If the behavior is complex enough, the number of tests describing it can grow dramatically and may become unmanageable. Luckily, most unit testing frameworks provide functionality that allows you to group similar tests using parameterized tests (see figure 3.2). In this section, I’ll first show each such behavior component described by a separate test and then demonstrate how these tests can be grouped together.

图 3.2。一个典型的应用程序表现出多种行为。行为越复杂,完整描述它所需的事实就越多。每个事实都由一个测试表示。可以使用参数化测试将类似的事实分组到单个测试方法中。

Figure 3.2. A typical application exhibits multiple behaviors. The greater the complexity of the behavior, the more facts are required to fully describe it. Each fact is represented by a test. Similar facts can be grouped into a single test method using parameterized tests.

CH03 图 2 行为与事实

假设我们的交付功能的工作方式是允许的最快交付日期是从现在起两天。显然,我们拥有的一项测试是不够的。除了检查过去交货日期的测试之外,我们还需要检查今天、明天和之后的日期的测试。

Let’s say that our delivery functionality works in such a way that the soonest allowed delivery date is two days from now. Clearly, the one test we have isn’t enough. In addition to the test that checks for a past delivery date, we’ll also need tests that check for today’s date, tomorrow’s date, and the date after that.

现有测试称为Delivery_with_a_past_date_is_invalid. 我们还可以添加三个:

The existing test is called Delivery_with_a_past_date_is_invalid. We could add three more:

public void Delivery_for_today_is_invalid()
public void Delivery_for_tomorrow_is_invalid()
public void The_soonest_delivery_date_is_two_days_from_now()
public void Delivery_for_today_is_invalid()
public void Delivery_for_tomorrow_is_invalid()
public void The_soonest_delivery_date_is_two_days_from_now()

但这将导致四种测试方法,它们之间的唯一区别是交货日期。

But that would result in four test methods, with the only difference between them being the delivery date.

更好的方法是将这些测试组合成一个,以减少测试代码量。xUnit(与大多数其他测试框架一样)有一个称为参数化测试的特性,可以让您做到这一点。下一个清单显示了这种分组的外观。每个InlineData属性代表一个关于系统的独立事实;它本身就是一个测试用例。

A better approach is to group these tests into one in order to reduce the amount of test code. xUnit (like most other test frameworks) has a feature called parameterized tests that allows you to do exactly that. The next listing shows how such grouping looks. Each InlineData attribute represents a separate fact about the system; it’s a test case in its own right.

清单 3.11。包含多个事实的测试

Listing 3.11. A test that encompasses several facts

公共类 DeliveryServiceTests
{
    [内联数据(-1,假)]    
    [InlineData(0, false)]     
    [InlineData(1, false)]     
    [内联数据(2, 真)]     
    [理论]
    公共无效 Can_detect_an_invalid_delivery_date(
        int daysFromNow,   
        布尔预期)     
    {
        DeliveryService sut = new DeliveryService();
        日期时间 deliveryDate = DateTime.Now
            .AddDays(daysFromNow);   
        交货交货=新交货
        {
            日期 = 交货日期
        };

        bool isValid = sut.IsDeliveryValid(交付);

        Assert.Equal(expected, isValid);   
    }
}
public class DeliveryServiceTests
{
    [InlineData(-1, false)]   
    [InlineData(0, false)]    
    [InlineData(1, false)]    
    [InlineData(2, true)]     
    [Theory]
    public void Can_detect_an_invalid_delivery_date(
        int daysFromNow,   
        bool expected)     
    {
        DeliveryService sut = new DeliveryService();
        DateTime deliveryDate = DateTime.Now
            .AddDays(daysFromNow);   
        Delivery delivery = new Delivery
        {
            Date = deliveryDate
        };

        bool isValid = sut.IsDeliveryValid(delivery);

        Assert.Equal(expected, isValid);   
    }
}

InlineData 属性将一组输入值发送到测试方法。每行代表一个关于行为的单独事实。

The InlineData attribute sends a set of input values to the test method. Each line represents a separate fact about the behavior.

属性附加输入值的参数

Parameters to which the attributes attach the input values

使用参数

Uses the parameters

 

 

[提示] 提示

请注意使用属性[Theory]而不是[Fact]。理论是关于行为的一堆事实。

Notice the use of the [Theory] attribute instead of [Fact]. A theory is a bunch of facts about the behavior.

每个事实现在都由一条线表示,[InlineData]而不是单独的测试。我还将测试方法重命名为更通用的名称:它不再提及构成有效日期或无效日期的内容。

Each fact is now represented by an [InlineData] line rather than a separate test. I also renamed the test method something more generic: it no longer mentions what constitutes a valid or invalid date.

使用参数化测试,您可以显着减少测试代码的数量,但这种好处是有代价的。现在很难弄清楚测试方法代表什么事实。而且参数越多,就越难。作为折衷方案,您可以将正测试用例提取到它自己的测试中,并在最重要的地方从描述性命名中获益——确定有效和无效交付日期的区别。

Using parameterized tests, you can significantly reduce the amount of test code, but this benefit comes at a cost. It’s now hard to figure out what facts the test method represents. And the more parameters there are, the harder it becomes. As a compromise, you can extract the positive test case into its own test and benefit from the descriptive naming where it matters the most — in determining what differentiates valid and invalid delivery dates.

清单 3.12。验证正面和负面场景的两个测试

Listing 3.12. Two tests verifying the positive and negative scenarios

公共类 DeliveryServiceTests
{
    [内联数据(-1)]
    [内联数据(0)]
    [内联数据(1)]
    [理论]
    public void Detects_an_invalid_delivery_date(int daysFromNow)
    {
        /* ... */
    }

    [事实]
    public void The_soonest_delivery_date_is_two_days_from_now()
    {
        /* ... */
    }
}
public class DeliveryServiceTests
{
    [InlineData(-1)]
    [InlineData(0)]
    [InlineData(1)]
    [Theory]
    public void Detects_an_invalid_delivery_date(int daysFromNow)
    {
        /* ... */
    }

    [Fact]
    public void The_soonest_delivery_date_is_two_days_from_now()
    {
        /* ... */
    }
}

这种方法还简化了否定测试用例,因为您可以expected从测试方法中删除布尔参数。当然,您也可以将正测试方法转换为参数化测试,以测试多个日期。

This approach also simplifies the negative test cases, since you can remove the expected Boolean parameter from the test method. And, of course, you can transform the positive test method into a parameterized test as well, to test multiple dates.

如您所见,测试代码的数量和该代码的可读性之间存在权衡。根据经验,仅当从输入参数中不言而喻哪个案例代表什么时,才将正面和负面测试用例放在一个方法中。否则,提取正面测试用例。如果行为太复杂,则根本不要使用参数化测试。用自己的测试方法表示每个负面和正面测试用例。

As you can see, there’s a trade-off between the amount of test code and the readability of that code. As a rule of thumb, keep both positive and negative test cases together in a single method only when it’s self-evident from the input parameters which case stands for what. Otherwise, extract the positive test cases. And if the behavior is too complicated, don’t use the parameterized tests at all. Represent each negative and positive test case with its own test method.

3.5.1 为参数化测试生成数据

3.5.1  Generating data for parameterized tests

在使用参数化测试(至少在 .NET 中)时需要注意一些注意事项。请注意,在清单3.11中,我使用daysFromNow参数作为测试方法的输入。您可能会问,为什么不是实际的日期和时间?不幸的是,以下代码将不起作用:

There are some caveats in using parameterized tests (at least, in .NET) that you need to be aware of. Notice that in listing 3.11, I used the daysFromNow parameter as an input to the test method. Why not the actual date and time, you might ask? Unfortunately, the following code won’t work:

[InlineData( DateTime.Now.AddDays(-1) , false)]
[InlineData( DateTime.Now , false)]
[InlineData( DateTime.Now.AddDays(1) , false)]
[InlineData( DateTime.Now.AddDays(2) , true)]
[理论]
公共无效 Can_detect_an_invalid_delivery_date(
    日期时间交货日期,
    预计布尔值)
{
    DeliveryService sut = new DeliveryService();
    交货交货=新交货
    {
        日期 =交货日期
    };

    bool isValid = sut.IsDeliveryValid(交付);

    Assert.Equal(expected, isValid);
}
[InlineData(DateTime.Now.AddDays(-1), false)]
[InlineData(DateTime.Now, false)]
[InlineData(DateTime.Now.AddDays(1), false)]
[InlineData(DateTime.Now.AddDays(2), true)]
[Theory]
public void Can_detect_an_invalid_delivery_date(
    DateTime deliveryDate,
    bool expected)
{
    DeliveryService sut = new DeliveryService();
    Delivery delivery = new Delivery
    {
        Date = deliveryDate
    };

    bool isValid = sut.IsDeliveryValid(delivery);

    Assert.Equal(expected, isValid);
}

在 C# 中,所有属性的内容都在编译时求值。您必须仅使用编译器可以理解的那些值,如下所示:

In C#, the content of all attributes is evaluated at compile time. You have to use only those values that the compiler can understand, which are as follows:

  • 常量

  • Constants

  • 文字

  • Literals

  • typeof()表达

  • typeof() expressions

对 的调用DateTime.Now依赖于 .NET 运行时,因此是不允许的。

The call to DateTime.Now relies on the .NET runtime and thus is not allowed.

有一种方法可以克服这个问题。xUnit 还有另一个功能,您可以使用它来生成自定义数据以输入测试方法:[MemberData]. 下一个清单显示了我们如何使用此功能重写之前的测试。

There is a way to overcome this problem. xUnit has another feature that you can use to generate custom data to feed into the test method: [MemberData]. The next listing shows how we can rewrite the previous test using this feature.

清单 3.13。为参数化测试生成复杂数据

Listing 3.13. Generating complex data for the parameterized test

[理论]
[成员数据(名称(数据))]
公共无效 Can_detect_an_invalid_delivery_date(
    日期时间交货日期,
    预计布尔值)
{
    /* ... */
}

公共静态列表<对象[]> 数据()
{
    返回新列表<object[]>
    {
        新对象[] { DateTime.Now.AddDays(-1), false },
        新对象[] { DateTime.Now, false },
        新对象[] { DateTime.Now.AddDays(1), false },
        新对象 [] { DateTime.Now.AddDays(2), true }
    };
}
[Theory]
[MemberData(nameof(Data))]
public void Can_detect_an_invalid_delivery_date(
    DateTime deliveryDate,
    bool expected)
{
    /* ... */
}

public static List<object[]> Data()
{
    return new List<object[]>
    {
        new object[] { DateTime.Now.AddDays(-1), false },
        new object[] { DateTime.Now, false },
        new object[] { DateTime.Now.AddDays(1), false },
        new object[] { DateTime.Now.AddDays(2), true }
    };
}

MemberData接受生成输入数据集合的静态方法的名称(编译器翻译nameof(Data)"Data"文字)。集合的每个元素本身就是一个映射到两个输入参数的集合:deliveryDateexpected。有了这个特性,你就可以克服编译器的限制,在参数化测试中使用任意类型的参数。

MemberData accepts the name of a static method that generates a collection of input data (the compiler translates nameof(Data) into a "Data" literal). Each element of the collection is itself a collection that is mapped into the two input parameters: deliveryDate and expected. With this feature, you can overcome the compiler’s restrictions and use parameters of any type in the parameterized tests.

3.6 使用断言库进一步提高测试可读性

3.6  Using an assertion library to further improve test readability

提高测试可读性的另一件事是使用断言库。我个人更喜欢 Fluent Assertions ( https://fluentassertions.com ),但是 .NET 在这个领域有几个相互竞争的库。

One more thing you can do to improve test readability is to use an assertion library. I personally prefer Fluent Assertions (https://fluentassertions.com), but .NET has several competing libraries in this area.

使用断言库的主要好处是如何重构断言以使其更具可读性。这是我们早期的测试之一:

The main benefit of using an assertion library is how you can restructure the assertions so that they are more readable. Here’s one of our earlier tests:

[事实]
公共无效 Sum_of_two_numbers()
{
    var sut = 新计算器();

    双结果 = sut.Sum(10, 20);

    Assert.Equal(30, 结果);
}
[Fact]
public void Sum_of_two_numbers()
{
    var sut = new Calculator();

    double result = sut.Sum(10, 20);

    Assert.Equal(30, result);
}

现在将其与以下使用流畅断言的代码进行比较:

Now compare it to the following, which uses a fluent assertion:

[事实]
公共无效 Sum_of_two_numbers()
{
    var sut = 新计算器();

    双结果 = sut.Sum(10, 20);

    结果.Should().Be(30);
}
[Fact]
public void Sum_of_two_numbers()
{
    var sut = new Calculator();

    double result = sut.Sum(10, 20);

    result.Should().Be(30);
}

第二个测试的断言读起来很简单,这正是您希望所有代码阅读的方式。作为人类,我们更喜欢以故事的形式吸收信息。所有的故事都遵循这个特定的模式:

The assertion from the second test reads as plain English, which is exactly how you want all your code to read. We as humans prefer to absorb information in the form of stories. All stories adhere to this specific pattern:

[主题] [行动] [对象]。
[Subject] [action] [object].

例如,

For example,

鲍勃打开门。
Bob opened the door.

这里,Bob是一个主体,opened是一个动作,the door是一个客体。同样的规则适用于代码。result.Should().Be(30)读起来比Assert.Equal(30, result)精确更好,因为它遵循故事模式。这是一个简单的故事,其中result有一个主题,should be一个动作,30一个对象。

Here, Bob is a subject, opened is an action, and the door is an object. The same rule applies to code. result.Should().Be(30) reads better than Assert.Equal(30, result) precisely because it follows the story pattern. It’s a simple story in which result is a subject, should be is an action, and 30 is an object.

 

 

[笔记] 笔记

顺便说一句,面向对象编程 (OOP) 的范式之所以成功,部分原因在于这种可读性优势。使用 OOP,您也可以按照读起来像故事的方式来构建代码。

By the way, the paradigm of object-oriented programming (OOP) has become a success partly because of this readability benefit. With OOP, you, too, can structure the code in a way that reads like a story.

Fluent Assertions 库还提供了许多辅助方法来对数字、字符串、集合、日期和时间等进行断言。唯一的缺点是这样的库是您可能不想引入到项目中的额外依赖项(尽管它仅用于开发,不会交付到生产环境)。

The Fluent Assertions library also provides numerous helper methods to assert against numbers, strings, collections, dates and times, and much more. The only drawback is that such a library is an additional dependency you may not want to introduce to your project (although it’s for development only and won’t be shipped to production).

3.7 总结

3.7  Summary

  • 所有单元测试都应遵循 AAA 模式:安排行动断言。如果测试有多个arrangeactassert部分,则表明该测试同时验证了多个行为单元。如果这个测试是一个单元测试,把它分成几个测试——每个动作一个。

  • All unit tests should follow the AAA pattern: arrange, act, assert. If a test has multiple arrange, act, or assert sections, that’s a sign that the test verifies multiple units of behavior at once. If this test is meant to be a unit test, split it into several tests — one per each action.

  • act部分中多于一行表示 SUT 的 API 有问题。它要求客户记住始终一起执行这些操作,这可能会导致不一致。这种不一致称为不变违规。保护您的代码免受潜在的不变违规的行为称为封装

  • More than one line in the act section is a sign of a problem with the SUT’s API. It requires the client to remember to always perform these actions together, which can potentially lead to inconsistencies. Such inconsistencies are called invariant violations. The act of protecting your code against potential invariant violations is called encapsulation.

  • 通过命名来区分测试中的 SUT sut。通过在它们之前放置ArrangeActAssert注释或在这些部分之间引入空行来区分三个测试部分。

  • Distinguish the SUT in tests by naming it sut. Differentiate the three test sections either by putting Arrange, Act, and Assert comments before them or by introducing empty lines between these sections.

  • 通过引入工厂方法来重用测试夹具初始化代码,而不是将此初始化代码放入构造函数中。这种重用有助于保持测试之间的高度解耦,并提供更好的可读性。

  • Reuse test fixture initialization code by introducing factory methods, not by putting this initialization code to the constructor. Such reuse helps maintain a high degree of decoupling between tests and also provides better readability.

  • 不要使用严格的测试命名策略。为每个测试命名,就好像您正在向熟悉问题域的非程序员描述其中的场景一样。测试名称中的单词用下划线分隔,测试名称中不要包含被测方法的名称。

  • Don’t use a rigid test naming policy. Name each test as if you were describing the scenario in it to a non-programmer who is familiar with the problem domain. Separate words in the test name by underscores, and don’t include the name of the method under test in the test name.

  • 参数化测试有助于减少类似测试所需的代码量。缺点是当您使测试名称更通用时,测试名称的可读性会降低。

  • Parameterized tests help reduce the amount of code needed for similar tests. The drawback is that the test names become less readable as you make them more generic.

  • 断言库通过重组断言中的词序来帮助您进一步提高测试的可读性,使它们读起来像简单的英语。

  • Assertion libraries help you further improve test readability by restructuring the word order in assertions so that they read like plain English.

第2部分

Part 2

让你的测试为你工作

Making your tests work for you

既然您已经了解了单元测试的用途,您就可以深入了解什么是好的测试的关键,并学习如何重构您的测试以使其更有价值。

Now that you’re armed with the knowledge of what unit testing is for, you’re ready to dive into the very crux of what makes a good test and learn how to refactor your tests towards being more valuable.

在第 4 章中,您将了解构成良好单元测试的四大支柱。这四个支柱奠定了基础,一个共同的参考框架,我们将使用它来分析单元测试和测试方法。

In chapter 4, you’ll learn about the four pillars that comprise a good unit test. These four pillars set a foundation, a common frame of reference, which we’ll use to analyze unit tests and testing approaches moving forward.

第 5 章采用第 4 章中建立的参考框架,构建模拟案例及其与测试脆弱性的关系。

Chapter 5 takes the frame of reference established in chapter 4 and builds the case for mocks and their relation to test fragility.

第 6 章使用相同的参考框架来检查单元测试的三种风格。它显示了这些风格中的哪些倾向于产生质量最好的测试以及为什么。

Chapter 6 uses the same the frame of reference to examine the three styles of unit testing. It shows which of those styles tends to produce tests of the best quality and why.

第 7 章将第 4 章到第 6 章的知识付诸实践,并教您如何从臃肿、过于复杂的测试重构为以尽可能低的维护成本提供尽可能多的价值的测试。

Chapter 7 puts the knowledge from chapters 4 to 6 into practice and teaches you how to refactor away from bloated, overcomplicated tests to tests that provide as much value with as little maintenance costs as possible.

4

4

良好单元测试的四大支柱

The four pillars of a good unit test

本章涵盖:

This chapter covers:

  • 探索良好单元测试各个方面之间的二分法

  • Exploring dichotomies between aspects of a good unit test

  • 定义一个理想的测试

  • Defining an ideal test

  • 了解测试金字塔

  • Understanding the Test Pyramid

  • 使用黑盒和白盒测试

  • Using black-box and white-box testing

现在我们要进入问题的核心了。在第 1 章中,您看到了一个好的单元测试套件的属性:

Now we are getting to the heart of the matter. In chapter 1, you saw the properties of a good unit test suite:

  • 它被集成到开发周期中。您只能从积极使用的测试中获得价值;否则写它们是没有意义的。

  • It is integrated into the development cycle. You only get value from tests that you actively use; there’s no point in writing them otherwise.

  • 它只针对代码库中最重要的部分。并非所有生产代码都值得同等关注。将应用程序的核心(其领域模型)与其他一切区分开来很重要。这个主题在第 7 章中讨论。

  • It targets only the most important parts of your code base. Not all production code deserves equal attention. It’s important to differentiate the heart of the application (its domain model) from everything else. This topic is tackled in chapter 7.

  • 它以最低的维护成本提供最大的价值。要实现这最后一个属性,您需要能够

    • 识别有价值的测试(并且,推而广之,低价值的测试)

    • 写一个有价值的测试

  • It provides maximum value with minimum maintenance costs. To achieve this last attribute, you need to be able to

    • Recognize a valuable test (and, by extension, a test of low value)

    • Write a valuable test

正如我们在第 1 章中讨论的那样,识别有价值的测试编写有价值的测试是两种不同的技能。不过,后一种技能需要前一种技能;因此,在本章中,我将展示如何识别有价值的测试。您将看到一个通用的参考框架,您可以使用它来分析套件中的任何测试。然后我们将使用这个参考框架来复习一些流行的单元测试概念:测试金字塔和黑盒测试与白盒测试。

As we discussed in chapter 1, recognizing a valuable test and writing a valuable test are two separate skills. The latter skill requires the former one, though; so, in this chapter, I’ll show how to recognize a valuable test. You’ll see a universal frame of reference with which you can analyze any test in the suite. We’ll then use this frame of reference to go over some popular unit testing concepts: the Test Pyramid and black-box versus white-box testing.

系好安全带:我们开始了。

Buckle up: we are starting out.

4.1 深入了解良好单元测试的四大支柱

4.1  Diving into the four pillars of a good unit test

一个好的单元测试具有以下四个属性:

A good unit test has the following four attributes:

  • 防止回归

  • Protection against regressions

  • 抵制重构

  • Resistance to refactoring

  • 快速反馈

  • Fast feedback

  • 可维护性

  • Maintainability

这四个属性是基础的。您可以使用它们来分析任何自动化测试,无论是单元测试、集成测试还是端到端测试。每个这样的测试都展示了每个属性的某种程度。在本节中,我定义了前两个属性;在 4.2 节中,我描述了它们之间的内在联系。

These four attributes are foundational. You can use them to analyze any automated test, be it unit, integration, or end-to-end. Every such test exhibits some degree of each attribute. In this section, I define the first two attributes; and in section 4.2, I describe the intrinsic connection between them.

4.1.1 第一支柱:防止倒退

4.1.1  The first pillar: Protection against regressions

让我们从一个好的单元测试的第一个属性开始:防止回归。正如您从第 1 章中了解到的,回归是一种软件错误。这是在某些代码修改后功能停止按预期工作的时候,通常是在您推出新功能之后。

Let’s start with the first attribute of a good unit test: protection against regressions. As you know from chapter 1, a regression is a software bug. It’s when a feature stops working as intended after some code modification, usually after you roll out new functionality.

这种倒退很烦人(至少可以这么说),但这并不是它们最糟糕的部分。最糟糕的是,你开发的功能越多,你就越有可能在新版本中破坏其中一个功能。编程生活中一个不幸的事实是,代码不是资产,而是负债。代码库越大,它暴露于潜在错误的可能性就越大。这就是为什么制定良好的回归保护措施至关重要的原因。没有这样的保护,您将无法长期维持项目的增长——您将被越来越多的错误所淹没。

Such regressions are annoying (to say the least), but that’s not the worst part about them. The worst part is that the more features you develop, the more chances there are that you’ll break one of those features with a new release. An unfortunate fact of programming life is that code is a not an asset, it’s a liability. The larger the code base, the more exposure it has to potential bugs. That’s why it’s crucial to develop a good protection against regressions. Without such protection, you won’t be able to sustain the project growth in a long run — you’ll be buried under an ever-increasing number of bugs.

要评估测试在防止回归指标上的得分情况,您需要考虑以下因素:

To evaluate how well a test scores on the metric of protecting against regressions, you need to take into account the following:

  • 测试期间执行的代码量

  • The amount of code that is executed during the test

  • 该代码的复杂性

  • The complexity of that code

  • 代码的域意义

  • The code’s domain significance

通常,执行的代码量越大,测试揭示回归的可能性就越大。当然,假设这个测试有一组相关的断言,您不想仅仅执行代码。虽然了解此代码运行时不会抛出异常会有所帮助,但您还需要验证它产生的结果。

Generally, the larger the amount of code that gets executed, the higher the chance that the test will reveal a regression. Of course, assuming that this test has a relevant set of assertions, you don’t want to merely execute the code. While it helps to know that this code runs without throwing exceptions, you also need to validate the outcome it produces.

请注意,重要的不仅是代码量,还有它的复杂性和领域重要性。代表复杂业务逻辑的代码比样板代码更重要——关键业务功能中的错误最具破坏性。

Note that it’s not only the amount of code that matters, but also its complexity and domain significance. Code that represents complex business logic is more important than boilerplate code — bugs in business-critical functionality are the most damaging.

另一方面,很少值得测试琐碎的代码。这样的代码很短,不包含大量的业务逻辑。涵盖琐碎代码的测试不太可能发现回归错误,因为没有太多的错误空间。简单代码的一个示例是这样的单行属性:

On the other hand, it’s rarely worthwhile to test trivial code. Such code is short and doesn’t contain a substantial amount of business logic. Tests that cover trivial code don’t have much of a chance of finding a regression error, because there’s not a lot of room for a mistake. An example of trivial code is a single-line property like this:

公共类用户
{
    公共字符串名称 { 得到; 放; }
}
public class User
{
    public string Name { get; set; }
}

此外,除了您的代码之外,您没有编写的代码也很重要:例如,库、框架和项目中使用的任何外部系统。该代码对软件工作的影响几乎与您自己的代码一样大。为了获得最好的保护,测试必须将那些库、框架和外部系统包括在测试范围内,以检查您的软件对这些依赖项所做的假设是否正确。

Furthermore, in addition to your code, the code you didn’t write also counts: for example, libraries, frameworks, and any external systems used in the project. That code influences the working of your software almost as much as your own code. For the best protection, the test must include those libraries, frameworks, and external systems in the testing scope, in order to check that the assumptions your software makes about these dependencies are correct.

 

 

[提示] 提示

为了最大化防止回归的指标,测试需要旨在执行尽可能多的代码。

To maximize the metric of protection against regressions, the test needs to aim at exercising as much code as possible.

4.1.2 第二支柱:抵制重构

4.1.2  The second pillar: Resistance to refactoring

好的单元测试的第二个属性是抗重构 ——测试可以在多大程度上维持底层应用程序代码的重构而不变红(失败)。

The second attribute of a good unit test is resistance to refactoring — the degree to which a test can sustain a refactoring of the underlying application code without turning red (failing).

定义 4.1:

Definition 4.1:

重构意味着在不修改其可观察行为的情况下更改现有代码。目的通常是改进代码的非功能特性:增加可读性和降低复杂性。重构的一些示例是重命名方法并将一段代码提取到新类中。

Refactoring means changing existing code without modifying its observable behavior. The intention is usually to improve the code’s nonfunctional characteristics: increase readability and reduce complexity. Some examples of refactoring are renaming a method and extracting a piece of code into a new class.

想象一下这种情况。您开发了一项新功能,并且一切正常。该功能本身正在发挥作用,所有测试都通过了。现在您决定清理代码。你在这里做一些重构,在那里做一点修改,一切看起来都比以前更好。除了一件事——测试失败了。您更仔细地查看重构究竟破坏了什么,但事实证明您没有破坏任何东西。该功能与以前一样完美运行。问题是测试的编写方式使得它们随着底层代码的任何修改而变成红色。他们这样做,不管你是否真的破坏了功能本身。

Picture this situation. You developed a new feature, and everything works great. The feature itself is doing its job, and all the tests are passing. Now you decide to clean up the code. You do some refactoring here, a little bit of modification there, and everything looks even better than before. Except one thing — the tests are failing. You look more closely to see exactly what you broke with the refactoring, but it turns out that you didn’t break anything. The feature works perfectly, just as before. The problem is that the tests are written in such a way that they turn red with any modification of the underlying code. And they do that regardless of whether you actually break the functionality itself.

这种情况称为误报。误报是误报。这是一个表明测试失败的结果,尽管实际上,它涵盖的功能按预期工作。这种误报通常发生在您重构代码时——当您修改实现但保持可观察行为不变时。因此,一个好的单元测试的这个属性的名称是:resistance to refactoring

This situation is called a false positive. A false positive is a false alarm. It’s a result indicating that the test fails, although in reality, the functionality it covers works as intended. Such false positives usually take place when you refactor the code — when you modify the implementation but keep the observable behavior intact. Hence the name for this attribute of a good unit test: resistance to refactoring.

要评估测试在抵制重构指标上的得分如何,您需要查看测试产生了多少误报。越少越好。

To evaluate how well a test scores on the metric of resisting to refactoring, you need to look at how many false positives the test generates. The fewer, the better.

为什么如此关注误报?因为它们会对您的整个测试套件产生毁灭性的影响。您可能还记得第 1 章,单元测试的目标是实现可持续的项目增长。测试实现可持续增长的机制是它们允许您添加新功能并进行定期重构而不引入回归。这里有两个具体的好处:

Why so much attention on false positives? Because they can have a devastating effect on your entire test suite. As you may recall from chapter 1, the goal of unit testing is to enable sustainable project growth. The mechanism by which the tests enable sustainable growth is that they allow you to add new features and conduct regular refactorings without introducing regressions. There are two specific benefits here:

  • 当您破坏现有功能时,测试会提供早期警告。多亏了这样的早期警告,您可以在将错误代码部署到生产环境之前很久就解决问题,而在生产环境中处理问题需要付出更多的努力。

  • Tests provide an early warning when you break existing functionality. Thanks to such early warnings, you can fix an issue long before the faulty code is deployed to production, where dealing with it would require a significantly larger amount of effort.

  • 您确信您的代码更改不会导致回归。没有这样的信心,您将更不愿意重构,并且更有可能让代码库恶化。

  • You become confident that your code changes won’t lead to regressions. Without such confidence, you will be much more hesitant to refactor and much more likely to leave the code base to deteriorate.

误报会干扰这两个好处:

False positives interfere with both of these benefits:

  • 如果测试无故失败,它们会削弱您对代码中的问题做出反应的能力和意愿。随着时间的推移,您会习惯于此类失败并不再关注。一段时间后,您也开始忽略合法故障,让它们溜进生产环境。

  • If tests fail with no good reason, they dilute your ability and willingness to react to problems in code. Over time, you get accustomed to such failures and stop paying as much attention. After a while, you start ignoring legitimate failures, too, allowing them to slip into production.

  • 另一方面,当误报频繁发生时,您会慢慢失去对测试套件的信任。您不再将其视为可靠的安全网——错误警报削弱了这种感觉。这种缺乏信任会导致更少的重构,因为您试图将代码更改减少到最低限度以避免回归。

  • On the other hand, when false positives are frequent, you slowly lose trust in the test suite. You no longer perceive it as a reliable safety net — the perception is diminished by false alarms. This lack of trust leads to fewer refactorings, because you try to reduce code changes to a minimum in order to avoid regressions.

战壕里的故事

A story from the trenches

我曾经参与过一个历史悠久的项目。这个项目不是太老,可能有两三年;但在那段时间里,管理层显着改变了项目的发展方向,开发也相应地改变了方向。在这次变革中,出现了一个问题:代码库积累了大量的遗留代码,没有人敢删除或重构。公司不再需要代码提供的功能,但其中的某些部分用于新功能,因此不可能完全摆脱旧代码。

I once worked on a project with a rich history. The project wasn’t too old, maybe two or three years; but during that period of time, management significantly shifted the direction they wanted to go with the project, and development changed direction accordingly. During this change, a problem emerged: the code base accumulated large chunks of left-over code that no one dared to delete or refactor. The company no longer needed the features that code provided, but some parts of it were used in new functionality, so it was impossible to get rid of the old code completely.

该项目具有良好的测试覆盖率。但每次有人试图重构旧功能并将仍在使用的部分与其他所有部分分开时,测试都会失败。不仅是旧测试——它们很久以前就被禁用了——还有新测试。有些失败是合理的,但大多数都不是——它们是误报。

The project had good test coverage. But every time someone tried to refactor the old features and separate the bits that were still in use from everything else, the tests failed. And not just the old tests — they had been disabled long ago — but the new tests, too. Some of the failures were legitimate, but most were not — they were false positives.

起初,开发人员试图处理测试失败的问题。然而,由于其中绝大多数是误报,情况发展到开发人员忽略此类故障并禁用失败测试的地步。普遍的态度是,“如果是因为那段旧代码,就禁用测试;我们稍后再看。”

At first, the developers tried to deal with the test failures. However, since the vast majority of them were false alarms, the situation got to the point where the developers ignored such failures and disabled the failing tests. The prevailing attitude was, "If it’s because of that old chunk of code, just disable the test; we’ll look at it later."

有一段时间一切都很好——直到一个主要的错误进入了生产环境。其中一项测试正确地识别了错误,但没有人听;该测试与所有其他测试一起被禁用。在那次事故之后,开发人员完全停止接触旧代码。

Everything worked fine for a while — until a major bug slipped into production. One of the tests correctly identified the bug, but no one listened; the test was disabled along with all the others. After that accident, the developers stopped touching the old code entirely.

这个故事在大多数具有脆弱测试的项目中都很典型。首先,开发人员从表面上看测试失败并相应地处理它们。一段时间后,人们厌倦了一直喊“狼来了”的测试,开始越来越忽视它们。最终,会出现一堆真正的错误被发布到生产中的时刻,因为开发人员忽略了失败以及所有误报。

This story is typical of most projects with brittle tests. First, developers take test failures at face value and deal with them accordingly. After a while, people get tired of tests crying "wolf" all the time and start to ignore them more and more. Eventually, there comes a moment when a bunch of real bugs are released to production because developers ignored the failures along with all the false positives.

但是,您不想通过停止所有重构来应对这种情况。正确的反应是重新评估测试套件并开始降低其脆弱性。我在第 7 章中介绍了这个主题。

You don’t want to react to such a situation by ceasing all refactorings, though. The correct response is to re-evaluate the test suite and start reducing its brittleness. I cover this topic in chapter 7.

4.1.3 什么导致误报?

4.1.3  What causes false positives?

那么,是什么导致误报呢?你怎么能避免它们呢?

So, what causes false positives? And how can you avoid them?

测试产生的误报数量与测试的结构方式直接相关。测试与被测系统 (SUT) 的实施细节耦合得越多,它产生的误报就越多。减少误报几率的唯一方法是将测试与这些实现细节分离。您需要确保测试验证了 SUT 提供的最终结果:它的可观察行为,而不是执行该操作所采取的步骤。测试应该从最终用户的角度接近 SUT 验证,并且只检查对最终用户有意义的结果。必须忽略其他所有内容(第 5 章将详细介绍该主题)。

The number of false positives a test produces is directly related to the way the test is structured. The more the test is coupled to the implementation details of the system under test (SUT), the more false alarms it generates. The only way to reduce the chance of getting a false positive is to decouple the test from those implementation details. You need to make sure the test verifies the end result the SUT delivers: its observable behavior, not the steps it takes to do that. Tests should approach SUT verification from the end user’s point of view and check only the outcome meaningful to that end user. Everything else must be disregarded (more on this topic in chapter 5).

构建测试的最佳方式是让它讲述一个关于问题域的故事。如果这样的测试失败,则该失败将意味着故事与实际应用程序行为之间存在脱节。这是唯一一种对您有益的测试失败类型——此类失败总是恰到好处,可以帮助您快速了解哪里出了问题。所有其他的失败都只是噪音,会把你的注意力从重要的事情上转移开。

The best way to structure a test is to make it tell a story about the problem domain. Should such a test fail, that failure would mean there’s a disconnect between the story and the actual application behavior. It’s the only type of test failure that benefits you — such failures are always on point and help you quickly understand what went wrong. All other failures are just noise that steer your attention away from things that matter.

看看下面的例子。在其中,该类MessageRenderer生成消息的 HTML 表示形式,其中包含标题、正文和页脚。

Take a look at the following example. In it, the MessageRenderer class generates an HTML representation of a message containing a header, a body, and a footer.

清单 4.1。生成消息的 HTML 表示

Listing 4.1. Generating an HTML representation of a message

公开课留言
{
    公共字符串标题 { 得到; 放; }
    公共字符串主体 { 得到; 放; }
    公共字符串页脚 { 得到; 放; }
}

公共接口 IRenderer
{
    字符串渲染(消息消息);
}

公共类 MessageRenderer : IRenderer
{
    public IReadOnlyList<IRenderer> SubRenderers { get; }

    公共 MessageRenderer()
    {
        SubRenderers = new List<IRenderer>
        {
            新的 HeaderRenderer(),
            新的身体渲染器(),
            新的 FooterRenderer()
        };
    }

    公共字符串渲染(消息消息)
    {
        返回子渲染器
            .Select(x => x.Render(message))
            .Aggregate("", (str1, str2) => str1 + str2);
    }
}
public class Message
{
    public string Header { get; set; }
    public string Body { get; set; }
    public string Footer { get; set; }
}

public interface IRenderer
{
    string Render(Message message);
}

public class MessageRenderer : IRenderer
{
    public IReadOnlyList<IRenderer> SubRenderers { get; }

    public MessageRenderer()
    {
        SubRenderers = new List<IRenderer>
        {
            new HeaderRenderer(),
            new BodyRenderer(),
            new FooterRenderer()
        };
    }

    public string Render(Message message)
    {
        return SubRenderers
            .Select(x => x.Render(message))
            .Aggregate("", (str1, str2) => str1 + str2);
    }
}

该类MessageRenderer包含几个子渲染器,它将消息部分的实际工作委托给这些子渲染器。然后它将结果组合成一个 HTML 文档。子渲染器使用 HTML 标签编排原始文本。例如:

The MessageRenderer class contains several sub-renderers to which it delegates the actual work on parts of the message. It then combines the result into an HTML document. The sub-renderers orchestrate the raw text with HTML tags. For example:

公共类 BodyRenderer : IRenderer
{
    公共字符串渲染(消息消息)
    {
        返回 $"<b>{message.Body}</b>";
    }
}
public class BodyRenderer : IRenderer
{
    public string Render(Message message)
    {
        return $"<b>{message.Body}</b>";
    }
}

如何MessageRenderer测试?一种可能的方法是分析此类所遵循的算法。

How can MessageRenderer be tested? One possible approach is to analyze the algorithm this class follows.

清单 4.2。验证MessageRenderer具有正确的结构

Listing 4.2. Verifying that MessageRenderer has the correct structure

[事实]
public void MessageRenderer_uses_correct_sub_renderers()
{
    var sut = new MessageRenderer();

    IReadOnlyList<IRenderer> renderers = sut.SubRenderers;

    Assert.Equal(3, renderers.Count);
    Assert.IsAssignableFrom<HeaderRenderer>(renderers[0]);
    Assert.IsAssignableFrom<BodyRenderer>(renderers[1]);
    Assert.IsAssignableFrom<FooterRenderer>(renderers[2]);
}
[Fact]
public void MessageRenderer_uses_correct_sub_renderers()
{
    var sut = new MessageRenderer();

    IReadOnlyList<IRenderer> renderers = sut.SubRenderers;

    Assert.Equal(3, renderers.Count);
    Assert.IsAssignableFrom<HeaderRenderer>(renderers[0]);
    Assert.IsAssignableFrom<BodyRenderer>(renderers[1]);
    Assert.IsAssignableFrom<FooterRenderer>(renderers[2]);
}

该测试检查子渲染器是否都是预期的类型并以正确的顺序出现,这假定MessageRenderer处理消息的方式也必须是正确的。该测试乍一看可能看起来不错,但它真的验证了MessageRenderer可观察到的行为吗?如果您重新排列子渲染器,或者用一个新的替换其中一个怎么办?这会导致错误吗?

This test checks to see if the sub-renderers are all of the expected types and appear in the correct order, which presumes that the way MessageRenderer processes messages must also be correct. The test might look good at first, but does it really verify MessageRenderer's observable behavior? What if you rearrange the sub-renderers, or replace one of them with a new one? Will that lead to a bug?

不必要。您可以更改子渲染器的组合,使生成的 HTML 文档保持不变。例如,您可以替换BodyRendererBoldRenderer,它的作用与 相同BodyRenderer。或者你可以摆脱所有的子渲染器并直接在MessageRenderer.

Not necessarily. You could change a sub-renderer’s composition in such a way that the resulting HTML document remains the same. For example, you could replace BodyRenderer with a BoldRenderer, which does the same job as BodyRenderer. Or you could get rid of all the sub-renderers and implement the rendering directly in MessageRenderer.

尽管如此,如果您执行其中任何一项,测试将变为红色,即使最终结果不会改变。这是因为测试耦合到 SUT 的实现细节,而不是 SUT 产生的结果。该测试检查算法并期望看到一个特定的实现,而不考虑同样适用的替代实现(见图4.1)。

Still, the test will turn red if you do any of that, even though the end result won’t change. That’s because the test couples to the SUT’s implementation details and not the outcome the SUT produces. This test inspects the algorithm and expects to see one particular implementation, without any consideration for equally applicable alternative implementations (see figure 4.1).

图 4.1。耦合到 SUT 算法的测试。这样的测试期望看到一个特定的实现(SUT 必须采取的特定步骤来交付结果),因此是脆弱的。对 SUT 实现的任何重构都将导致测试失败。

Figure 4.1. A test that couples to the SUT’s algorithm. Such a test expects to see one particular implementation (the specific steps the SUT must take to deliver the result) and therefore is brittle. Any refactoring of the SUT’s implementation would lead to a test failure.

CH04 图 1 实现细节

类的任何实质性重构都MessageRenderer将导致测试失败。请注意,重构过程是在不影响应用程序的可观察行为的情况下更改实现。正是因为测试关注的是实现细节,所以每次更改这些细节时它都会变成红色。因此,耦合到 SUT 的实现细节的测试不会抵抗重构。这样的测试展示了我之前描述的所有缺点:

Any substantial refactoring of the MessageRenderer class would lead to a test failure. Mind you, the process of refactoring is changing the implementation without affecting the application’s observable behavior. And it’s precisely because the test is concerned with the implementation details that it turns red every time you change those details. Therefore, tests that couple to the SUT’s implementation details are not resistant to refactoring. Such tests exhibit all the shortcomings I described previously:

  • 它们不会在出现回归时提供早期警告——由于相关性不大,您可以简单地忽略这些警告。

  • They don’t provide an early warning in the event of regressions — you simply ignore those warnings due to little relevance.

  • 它们阻碍了你重构的能力和意愿。难怪 — 知道在查找错误时测试无法判断哪条路是对的,谁愿意重构?

  • They hinder your ability and willingness to refactor. It’s no wonder — who would like to refactor, knowing that the tests can’t tell which way is up when it comes to finding bugs?

下一个清单显示了我遇到过的测试中最令人震惊的脆弱性示例,其中测试读取类的源代码MessageRenderer并将其与“正确”的实现进行比较。

The next listing shows the most egregious example of brittleness in tests that I’ve ever encountered, in which the test reads the source code of the MessageRenderer class and compares it to the "correct" implementation.

清单 4.3。MessageRenderer验证类的源代码

Listing 4.3. Verifying the source code of the MessageRenderer class

[事实]
public void MessageRenderer_is_implemented_correctly()
{
    字符串 sourceCode = File.ReadAllText(@"[path]\MessageRenderer.cs");

    断言.等于(
        @”
**公共类 MessageRenderer : IRenderer
{
    public IReadOnlyList<*IRenderer*> SubRenderers { get; }

    公共 MessageRenderer()
    {
        SubRenderers = new List<*IRenderer*>
        {
            新的 HeaderRenderer(),
            新的身体渲染器(),
            新的 FooterRenderer()
        };
    }

    public string Render(Message message) { /* ... */ }
}**“, 源代码);
}
[Fact]
public void MessageRenderer_is_implemented_correctly()
{
    string sourceCode = File.ReadAllText(@"[path]\MessageRenderer.cs");

    Assert.Equal(
        @"
**public class MessageRenderer : IRenderer
{
    public IReadOnlyList<*IRenderer*> SubRenderers { get; }

    public MessageRenderer()
    {
        SubRenderers = new List<*IRenderer*>
        {
            new HeaderRenderer(),
            new BodyRenderer(),
            new FooterRenderer()
        };
    }

    public string Render(Message message) { /* ... */ }
}**", sourceCode);
}

当然,这个测试简直太荒谬了。如果您在课堂上修改哪怕是最细微的细节,它都会失败MessageRenderer。同时,它与我之前提出的测试并没有什么不同。两者都坚持特定的实现,而没有考虑 SUT 的可观察行为。每次更改该实现时,两者都会变为红色。不过,不可否认,清单4.3中的测试将比清单4.2中的测试更频繁地中断。

Of course, this test is just plain ridiculous; it will fail should you modify even the slightest detail in the MessageRenderer class. At the same time, it’s not that different from the test I brought up earlier. Both insist on a particular implementation without taking into consideration the SUT’s observable behavior. And both will turn red each time you change that implementation. Admittedly, though, the test in listing 4.3 will break more often than the one in listing 4.2.

4.1.4 以最终结果而非实现细节为目标

4.1.4  Aim at the end result instead of implementation details

正如我之前提到的,避免测试中的脆弱性并增加它们对重构的抵抗力的唯一方法是将它们与 SUT 的实现细节分离——在测试和代码的内部工作之间保持尽可能远的距离,而不是旨在验证最终结果。让我们这样做:让我们将清单4.2中的测试重构为不那么脆弱的东西。

As I mentioned earlier, the only way to avoid brittleness in tests and increase their resistance to refactoring is to decouple them from the SUT’s implementation details — keep as much distance as possible between the test and the code’s inner workings, and instead aim at verifying the end result. Let’s do that: let’s refactor the test from listing 4.2 into something much less brittle.

首先,您需要问自己以下问题:您从中获得的最终结果是什么MessageRenderer?好吧,它是消息的 HTML 表示形式。这是唯一值得检查的东西,因为这是你从课堂上得到的唯一可观察到的结果。只要此 HTML 表示保持不变,就无需担心它是如何生成的。这样的实现细节是无关紧要的。下面的代码是新版本的测试。

To start off, you need to ask yourself the following question: what is the final outcome you get from MessageRenderer? Well, it’s the HTML representation of a message. And it’s the only thing that makes sense to check, since it’s the only observable result you get out of the class. As long as this HTML representation stays the same, there’s no need to worry about exactly how it’s generated. Such implementation details are irrelevant. The following code is the new version of the test.

清单 4.4。MessageRenderer验证产生的结果

Listing 4.4. Verifying the outcome that MessageRenderer produces

[事实]
public void Rendering_a_message()
{
    var sut = new MessageRenderer();
    var message = 新消息
    {
        标头 = "h",
        正文 = "b",
        页脚 = "f"
    };

    string html = sut.Render(消息);

    Assert.Equal("<h1>h</h1><b>b</b><i>f</i>", html);
}
[Fact]
public void Rendering_a_message()
{
    var sut = new MessageRenderer();
    var message = new Message
    {
        Header = "h",
        Body = "b",
        Footer = "f"
    };

    string html = sut.Render(message);

    Assert.Equal("<h1>h</h1><b>b</b><i>f</i>", html);
}

该测试将其视为MessageRenderer黑盒,只对其可观察到的行为感兴趣。因此,该测试对重构的抵抗力要强得多——只要 HTML 输出保持不变,它就不会关心您对 SUT 做了什么更改(图4.2)。

This test treats MessageRenderer as a black box and is only interested in its observable behavior. As a result, the test is much more resistant to refactoring — it doesn’t care what changes you make to the SUT as long as the HTML output remains the same (figure 4.2).

图 4.2。左侧的测试耦合到 SUT 的可观察行为,而不是实现细节。这样的测试可以抵抗重构——它几乎不会触发误报。

Figure 4.2. The test on the left couples to the SUT’s observable behavior as opposed to implementation details. Such a test is resistant to refactoring — it will trigger few, if any, false positives.

CH04 图 2 可观察到的行为

请注意此测试相对于原始版本的重大改进。它通过验证对最终用户有意义的唯一结果——消息在浏览器中的显示方式,使自己与业务需求保持一致。此类测试的失败总是恰到好处:它们传达了可能影响客户的应用程序行为的变化,因此应引起开发人员的注意。该测试几乎不会产生误报(如果有的话)。

Notice the profound improvement in this test over the original version. It aligns itself with the business needs by verifying the only outcome meaningful to end users — how a message is displayed in the browser. Failures of such a test are always on point: they communicate a change in the application behavior that can affect the customer and thus should be brought to the developer’s attention. This test will produce few, if any, false positives.

为什么很少而不是根本没有?因为仍然有可能会MessageRenderer破坏测试的变化。例如,您可以在Render()方法中引入一个新参数,从而导致编译错误。从技术上讲,这样的错误也算作误报。毕竟,测试不会因为应用程序行为的变化而失败。

Why few and not none at all? Because there could still be changes in MessageRenderer that would break the test. For example, you could introduce a new parameter in the Render() method, causing a compilation error. And technically, such an error counts as a false positive, too. After all, the test isn’t failing because of a change in the application’s behavior.

但是这种误报很容易修复。只需跟随编译器并向所有调用该Render()方法的测试添加一个新参数。更糟糕的误报是那些不会导致编译错误的误报。这种误报是最难处理的——它们看起来像是指向一个合法的错误,需要更多的时间来调查。

But this kind of false positive is easy to fix. Just follow the compiler and add a new parameter to all tests that invoke the Render() method. The worse false positives are those that don’t lead to compilation errors. Such false positives are the hardest to deal with — they seem as through they point to a legitimate bug and require much more time to investigate.

4.2 前两个属性的内在联系

4.2  The intrinsic connection between the first two attributes

正如我之前提到的,良好单元测试的前两个支柱之间存在内在联系—— 防止回归抵制重构。尽管从相反的角度来看,它们都有助于测试套件的准确性。随着时间的推移,这两个属性也往往会对项目产生不同的影响:虽然在项目启动后很快就对回归进行良好的保护很重要,但抵制重构的需求并不是立即发生的。

As I mentioned earlier, there’s an intrinsic connection between the first two pillars of a good unit test — protection against regressions and resistance to refactoring. They both contribute to the accuracy of the test suite, though from opposite perspectives. These two attributes also tend to influence the project differently over time: while it’s important to have good protection against regressions very soon after the project’s initiation, the need for resistance to refactoring is not immediate.

在这一节中,我谈到

In this section, I talk about

  • 最大限度地提高测试精度

  • Maximizing test accuracy

  • 假阳性和假阴性的重要性

  • The importance of false positives and false negatives

4.2.1 最大化测试精度

4.2.1  Maximizing test accuracy

让我们退后一步,看看有关测试结果的更广泛的情况。在代码正确性和测试结果方面,有四种可能的结果,如图4.3所示。测试可以通过或失败(表的行)。功能本身可以是正确的也可以是错误的(表的列)。

Let’s step back for a second and look at the broader picture with regard to test results. When it comes to code correctness and test results, there are four possible outcomes, as shown in figure 4.3. The test can either pass or fail (the rows of the table). And the functionality itself can be either correct or broken (the table’s columns).

图 4.3。防止回归和抵制重构之间的关系。防止回归可以防止漏报(II 类错误)。抵制重构可以最大限度地减少误报(I 类错误)的数量。

Figure 4.3. The relationship between protection against regressions and resistance to refactoring. Protection against regressions guards against false negatives (type II errors). Resistance to refactoring minimizes the number of false positives (type I errors).

CH04 图3 二分法

测试通过并且底层功能按预期工作的情况是正确的推断:测试正确地推断出系统的状态(其中没有错误)。这种工作功能和通过测试的组合的另一个术语是真阴性

The situation when the test passes and the underlying functionality works as intended is a correct inference: the test correctly inferred the state of the system (there are no bugs in it). Another term for this combination of working functionality and a passing test is true negative.

同样,当功能被破坏并且测试失败时,它也是一个正确的推理。那是因为您希望在功能无法正常工作时看到测试失败。这就是单元测试的重点。这种情况对应的术语是true positive

Similarly, when the functionality is broken and the test fails, it’s also a correct inference. That’s because you expect to see the test fail when the functionality is not working properly. That’s the whole point of unit testing. The corresponding term for this situation is true positive.

但是当测试没有发现错误时,那就是一个问题。这是右上象限,假阴性。这就是一个好的测试的第一个属性——防止回归——可以帮助你避免的。具有良好的回归保护的测试可以帮助您最大程度地减少假阴性的数量 - II 类错误。

But when the test doesn’t catch an error, that’s a problem. This is the upper-right quadrant, a false negative. And this is what the first attribute of a good test — protection against regressions — helps you avoid. Tests with a good protection against regressions help you to minimize the number of false negatives — type II errors.

另一方面,当功能正确但测试仍然显示失败时,存在对称情况。这是误报,误报。这就是第二个属性——抗重构——可以帮助你的。

On the other hand, there’s a symmetric situation when the functionality is correct but the test still shows a failure. This is a false positive, a false alarm. And this is what the second attribute — resistance to refactoring — helps you with.

所有这些术语(误报I 类错误等)都源于统计学,但也可以应用于分析测试套件。了解它们的最佳方法是考虑进行流感测试。当接受测试的人患有流感时,流感测试呈阳性。阳性这个词有点令人困惑,因为得了流感没有什么阳性的。但测试并没有评估整体情况。在测试的上下文中,肯定意味着某些条件集现在为真。这些是测试的创建者设置的反应条件。在这个特定的例子中,它是流感的存在。相反,没有流感会使流感检测呈阴性

All these terms (false positive, type I error and so on) have roots in statistics, but can also be applied to analyzing a test suite. The best way to wrap your head around them is to think of a flu test. A flu test is positive when the person taking the test has the flu. The term positive is a bit confusing because there’s nothing positive about having the flu. But the test doesn’t evaluate the situation as a whole. In the context of testing, positive means that some set of conditions is now true. Those are the conditions the creators of the test have set it to react to. In this particular example, it’s the presence of the flu. Conversely, the lack of flu renders the flu test negative.

现在,当您评估流感测试的准确性时,您会提出诸如假阳性假阴性之类的术语。假阳性和假阴性的概率告诉您流感测试的好坏:概率越低,测试越准确。

Now, when you evaluate how accurate the flu test is, you bring up terms such as false positive or false negative. The probability of false positives and false negatives tells you how good the flu test is: the lower that probability, the more accurate the test.

这种准确性是良好单元测试的前两个支柱。防止回归抵制重构旨在最大限度地提高测试套件的准确性。准确性指标本身由两个部分组成:

This accuracy is what the first two pillars of a good unit test are all about. Protection against regressions and resistance to refactoring aim at maximizing the accuracy of the test suite. The accuracy metric itself consists of two components:

  • 测试在指示错误存在方面有多好(缺乏假阴性,防止回归的范围)

  • How good the test is at indicating the presence of bugs (lack of false negatives, the sphere of protection against regressions)

  • 测试在表明没有错误方面有多好(没有误报,重构的阻力范围)

  • How good the test is at indicating the absence of bugs (lack of false positives, the sphere of resistance to refactoring)

考虑误报和漏报的另一种方法是根据信噪比。从图4.4的公式可以看出,有两种方法可以提高测试精度。第一个是增加分子signal:也就是说,使测试更好地发现回归。第二个是减少分母,噪声:使测试更好地避免误报。

Another way to think of false positives and false negatives is in terms of signal-to-noise ratio. As you can see from the formula in figure 4.4, there are two ways to improve test accuracy. The first is to increase the numerator, signal: that is, make the test better at finding regressions. The second is to reduce the denominator, noise: make the test better at not raising false alarms.

图 4.4。测试是准确的,因为它会产生强烈的信号(能够发现错误)并且噪声(误报)尽可能少。

Figure 4.4. A test is accurate insofar as it generates a strong signal (is capable of finding bugs) with as little noise (false alarms) as possible.

CH04 图 4 SignalNoise2

两者都至关重要。无法发现任何错误的测试是没有用的,即使它不会引发误报。同样,即使它能够找到代码中的所有错误,当它产生大量噪音时,测试的准确性也会变为零。这些发现完全淹没在无关信息的海洋中。

Both are critically important. There’s no use for a test that isn’t capable of finding any bugs, even if it doesn’t raise false alarms. Similarly, the test’s accuracy goes to zero when it generates a lot of noise, even if it’s capable of finding all the bugs in code. These findings are simply lost in the sea of irrelevant information.

4.2.2 假阳性和假阴性的重要性:动态

4.2.2  The importance of false positives and false negatives: The dynamics

短期内,假阳性不如假阴性严重。在项目开始时,收到一个错误的警告并不是什么大不了的事,而不是根本没有收到警告并冒着错误进入生产的风险。但随着项目的增长,误报开始对测试套件产生越来越大的影响(图4.5)。

In the short term, false positives are not as bad as false negatives. In the beginning of a project, receiving a wrong warning is not that big a deal as opposed to not being warned at all and running the risk of a bug slipping into production. But as the project grows, false positives start to have an increasingly large effect on the test suite (figure 4.5).

图 4.5。误报(误报)在一开始并没有那么大的负面影响。但随着项目的发展,它们变得越来越重要——与漏报(未注意到的错误)一样重要。

Figure 4.5. False positives (false alarms) don’t have as much of a negative effect in the beginning. But they become increasingly important as the project grows — as important as false negatives (unnoticed bugs).

CH04 图5 VS

为什么误报最初没有那么重要?因为重构的重要性也不是立竿见影的;它随着时间的推移逐渐增加。您不需要在项目开始时进行很多代码清理。新编写的代码通常闪闪发光且完美无缺。它还对您记忆犹新,因此即使测试出现误报,您也可以轻松重构它。

Why are false positives not as important initially? Because the importance of refactoring is also not immediate; it increases gradually over time. You don’t need to conduct many code clean-ups in the beginning of the project. Newly written code is often shiny and flawless. It’s also still fresh in your memory, so you can easily refactor it even if tests raise false alarms.

但随着时间的推移,代码库会恶化。它变得越来越复杂和混乱。因此,您必须开始定期进行重构以减轻这种趋势。否则,引入新功能的成本最终会变得令人望而却步。

But as time goes on, the code base deteriorates. It becomes increasingly complex and disorganized. Thus you have to start conducting regular refactorings in order to mitigate this tendency. Otherwise, the cost of introducing new features eventually becomes prohibitive.

随着重构需求的增加,测试中抵制重构的重要性也随之增加。正如我之前解释的那样,当测试不断地叫喊“狼来了”并且你不断收到关于不存在的错误的警告时,你就无法重构。您很快就会对此类测试失去信任,不再将它们视为可靠的反馈来源。

As the need for refactoring increases, the importance of resistance to refactoring in tests increases with it. As I explained earlier, you can’t refactor when the tests keep crying "wolf" and you keep getting warnings about bugs that don’t exist. You quickly lose trust in such tests and stop viewing them as a reliable source of feedback.

尽管保护您的代码免受误报非常重要,尤其是在项目的后期阶段,但很少有开发人员以这种方式看待误报。大多数人倾向于只关注改进良好单元测试的第一个属性——防止回归,这不足以构建有助于维持项目增长的有价值、高度准确的测试套件。

Despite the importance of protecting your code against false positives, especially in the later project stages, few developers perceive false positives this way. Most people tend to focus solely on improving the first attribute of a good unit test — protection against regressions, which is not enough to build a valuable, highly accurate test suite that helps sustain project growth.

当然,原因是进入后期阶段的项目要少得多,主要是因为它们很小,而且在项目变得太大之前就完成了开发。因此,开发人员面临的问题是未被注意到的错误,而不是大量涌入项目并阻碍所有重构工作的错误警报。因此,人们会相应地进行优化。然而,如果您从事中型到大型项目,则必须对漏报(未注意到的错误)和误报(误报)给予同等的关注。

The reason, of course, is that far fewer projects get to those later stages, mostly because they are small and the development finishes before the project becomes too big. Thus developers face the problem of unnoticed bugs more often than false alarms that swarm the project and hinder all refactoring undertakings. And so, people optimize accordingly. Nevertheless, if you work on a medium to large project, you have to pay equal attention to both false negatives (unnoticed bugs) and false positives (false alarms).

4.3 第三和第四支柱:快速反馈和可维护性

4.3  The third and fourth pillars: Fast feedback and maintainability

在本节中,我将讨论良好单元测试的两个剩余支柱:

In this section, I talk about the two remaining pillars of a good unit test:

  • 快速反馈

  • Fast feedback

  • 可维护性

  • Maintainability

您可能还记得第 2 章,快速反馈是单元测试的基本属性。测试越快,套件中的测试就越多,运行它们的频率也越高。

As you may remember from chapter 2, fast feedback is an essential property of a unit test. The faster the tests, the more of them you can have in the suite and the more often you can run them.

通过快速运行的测试,您可以大大缩短反馈循环,直到一旦您破坏代码,测试就会开始警告您有关错误的程度,从而将修复这些错误的成本几乎降低为零。另一方面,缓慢的测试会延迟反馈,并可能延长错误未被发现的时间,从而增加修复它们的成本。那是因为缓慢的测试会阻止您经常运行它们,因此会导致在错误的方向上浪费更多时间。

With tests that run quickly, you can drastically shorten the feedback loop, to the point where the tests begin to warn you about bugs as soon as you break the code, thus reducing the cost of fixing those bugs almost to zero. On the other hand, slow tests delay the feedback and potentially prolong the period during which the bugs remain unnoticed, thus increasing the cost of fixing them. That’s because slow tests discourage you from running them often, and therefore lead to wasting more time moving in a wrong direction.

最后,良好单元测试的第四个支柱,可维护性指标,评估维护成本。该指标由两个主要部分组成:

Finally, the fourth pillar of good units tests, the maintainability metric, evaluates maintenance costs. This metric consists of two major components:

  • 理解测试的难易程度 ——这个部分与测试的大小有关。测试中的代码行越少,测试的可读性就越高。在需要时更改小测试也更容易。当然,这是假设您不试图人为地压缩测试代码以减少行数。测试代码的质量与生产代码一样重要。编写测试时不要偷工减料;将测试代码视为一等公民。

  • How hard it is to understand the test — This component is related to the size of the test. The fewer lines of code in the test, the more readable the test is. It’s also easier to change a small test when needed. Of course, that’s assuming you don’t try to compress the test code artificially just to reduce the line count. The quality of the test code matters as much as the production code. Don’t cut corners when writing tests; treat the test code as a first-class citizen.

  • 运行测试有多难 ——如果测试使用进程外依赖项,您必须花时间保持这些依赖项可操作:重新启动数据库服务器,解决网络连接问题,等等。

  • How hard it is to run the test — If the test works with out-of-process dependencies, you have to spend time keeping those dependencies operational: reboot the database server, resolve network connectivity issues, and so on.

4.4 寻找理想的测试

4.4  In search of an ideal test

以下是一个好的单元测试的四个属性:

Here are the four attributes of a good unit test once again:

  • 防止回归

  • Protection against regressions

  • 抵制重构

  • Resistance to refactoring

  • 快速反馈

  • Fast feedback

  • 可维护性

  • Maintainability

当这四个属性相乘时,就决定了测试的价值。乘以我指的是数学意义上的;也就是说,如果测试在其中一个属性中为零,则其值也变为零:

These four attributes, when multiplied together, determine the value of a test. And by multiplied, I mean in a mathematical sense; that is, if a test gets zero in one of the attributes, its value turns to zero as well:

价值估计 = [0..1] * [0..1] * [0..1] * [0..1]
Value estimate = [0..1] * [0..1] * [0..1] * [0..1]

 

 

[提示] 提示

为了有价值,测试需要在所有四个类别中至少得分。

In order to be valuable, the test needs to score at least something in all four categories.

当然,不可能精确测量这些属性。没有代码分析工具可以插入测试并获得准确的数字。但是您仍然可以非常准确地评估测试,以了解测试在四个属性方面的表现。反过来,此评估会为您提供测试的价值估计,您可以使用它来决定是否将测试保留在套件中。

Of course, it’s impossible to measure these attributes precisely. There’s no code analysis tool you can plug a test into and get the exact numbers. But you can still evaluate the test pretty accurately to see where a test stands with regard to the four attributes. This evaluation, in turn, gives you the test’s value estimate, which you can use to decide whether to keep the test in the suite.

请记住,所有代码,包括测试代码,都是一种责任。为最低要求值设置一个相当高的阈值,只有满足这个阈值才允许在套件中进行测试。与大量平庸的测试相比,少量非常有价值的测试在维持项目增长方面做得更好。

Remember, all code, including test code, is a liability. Set a fairly high threshold for the minimum required value, and only allow tests in the suite if they meet this threshold. A small number of highly valuable tests will do a much better job sustaining project growth than a large number of mediocre tests.

我将很快展示一些例子。现在,让我们检查是否有可能创建一个理想的测试。

I’ll show some examples shortly. For now, let’s examine whether it’s possible to create an ideal test.

4.4.1 是否可以创建一个理想的测试?

4.4.1  Is it possible to create an ideal test?

理想的测试是在所有四个属性中得分最高的测试。0如果您为1每个属性取最小值和最大值,则理想的测试必须1包含所有属性。

An ideal test is a test that scores the maximum in all four attributes. If you take the minimum and maximum values as 0 and 1 for each of the attributes, an ideal test must get 1 in all of them.

不幸的是,不可能创建这样一个理想的测试。原因是前三个属性—— 防止回归抵抗重构快速反馈 ——是相互排斥的。不可能将它们全部最大化:您必须牺牲三个中的一个才能最大化剩余的两个。

Unfortunately, it’s impossible to create such an ideal test. The reason is that the first three attributes — protection against regressions, resistance to refactoring, and fast feedback — are mutually exclusive. It’s impossible to maximize them all: you have to sacrifice one of the three in order to max out the remaining two.

而且,由于乘法原理(参见上一节的估值计算),保持平衡更加棘手。你不能为了专注于其他属性而放弃其中一个属性。正如我之前提到的,在四个类别之一中得分为零的测试毫无价值。因此,您必须最大化这些属性,以免它们中的任何一个都减少太多。让我们看一些测试示例,这些测试旨在以牺牲第三个属性为代价来最大化三个属性中的两个,结果,其值接近于零。

Moreover, because of the multiplication principle (see the calculation of the value estimate in the previous section), it’s even trickier to keep the balance. You can’t just forgo one of the attributes in order to focus on the others. As I mentioned previously, a test that scores zero in one of the four categories is worthless. Therefore, you have to maximize these attributes in such a way that none of them is diminished too much. Let’s look at some examples of tests that aim at maximizing two out of three attributes at the expense of the third and, as a result, have a value that’s close to zero.

4.4.2 极端案例 #1:端到端测试

4.4.2  Extreme case #1: End-to-end tests

第一个例子是端到端测试。你可能还记得第 2 章,端到端测试是从最终用户的角度来看系统的。它们通常会遍历系统的所有组件,包括 UI、数据库和外部应用程序。

The first example is end-to-end tests. As you may remember from chapter 2, end-to-end tests look at the system from the end user’s perspective. They normally go through all of the system’s components, including the UI, database, and external applications.

由于端到端测试运行了大量代码,因此它们提供了最好的回归保护。事实上,在所有类型的测试中,端到端测试使用的代码最多——包括您的代码和您没有编写但在项目中使用的代码,例如外部库、框架和第三方应用程序。

Since end-to-end tests exercise a lot of code, they provide the best protection against regressions. In fact, of all types of tests, end-to-end tests exercise the most code — both your code and the code you didn’t write but use in the project, such as external libraries, frameworks, and third-party applications.

端到端测试也不受误报影响,因此对重构有很好的抵抗力。如果重构正确,则不会改变系统的可观察行为,因此不会影响端到端测试。这是此类测试的另一个优势:它们不强加任何特定的实现。端到端测试唯一关注的是从最终用户的角度来看功能的行为方式。它们尽可能从实现细节中删除,因为测试可能如此。

End-to-end tests are also immune to false positives and thus have a good resistance to refactoring. A refactoring, if done correctly, doesn’t change the system’s observable behavior and therefore doesn’t affect the end-to-end tests. That’s another advantage of such tests: they don’t impose any particular implementation. The only thing end-to-end tests look at is how a feature behaves from the end user’s point of view. They are as removed from implementation details as tests could possibly be.

然而,尽管有这些好处,端到端测试有一个主要缺点:它们很慢。任何仅依赖此类测试的系统都很难获得快速反馈。这对许多开发团队来说是一个交易破坏者。这就是为什么几乎不可能只用端到端测试来覆盖你的代码库。

However, despite these benefits, end-to-end tests have a major drawback: they are slow. Any system that relies solely on such tests would have a hard time getting rapid feedback. And that is a deal-breaker for many development teams. This is why it’s pretty much impossible to cover your code base with only end-to-end tests.

4.6显示了端到端测试在前三个单元测试指标方面的地位。这样的测试可以很好地防止回归错误和误报,但缺乏速度。

Figure 4.6 shows where end-to-end tests stand with regard to the first three unit testing metrics. Such tests provide great protection against both regression errors and false positives, but lack speed.

图 4.6。端到端测试为防止回归错误和误报提供了很好的保护,但它们未能达到快速反馈的指标。

Figure 4.6. End-to-end tests provide great protection against both regression errors and false positives, but they fail at the metric of fast feedback.

CH04 图6 端到端测试

4.4.3 极端情况#2:平凡测试

4.4.3  Extreme case #2: Trivial tests

另一个以牺牲第三个属性为代价最大化三个属性中的两个的例子是一个简单的测试。这样的测试覆盖了一段简单的代码,不太可能破坏,因为它太琐碎了。

Another example of maximizing two out of three attributes at the expense of the third is a trivial test. Such tests cover a simple piece of code, something that is unlikely to break because it’s too trivial.

清单 4.5。涵盖一段简单代码的简单测试

Listing 4.5. Trivial test covering a simple piece of code

公共类用户
{
    公共字符串名称 { 得到; 放; }   
}

[事实]
公共无效测试()
{
    var sut = new User();

    sut.Name = "约翰·史密斯";

    Assert.Equal("John Smith", sut.Name);
}
public class User
{
    public string Name { get; set; }   
}

[Fact]
public void Test()
{
    var sut = new User();

    sut.Name = "John Smith";

    Assert.Equal("John Smith", sut.Name);
}

像这样的单行代码不太可能包含错误。

One-liners like this are unlikely to contain bugs.

与端到端测试不同,琐碎的测试确实提供了快速反馈——它们运行得非常快。它们产生误报的可能性也相当低,因此它们对重构有很好的抵抗力。但是,简单的测试不太可能揭示任何回归,因为底层代码中没有太多的错误余地。

Unlike end-to-end tests, trivial tests do provide fast feedback — they run very quickly. They also have a fairly low chance of producing a false positive, so they have good resistance to refactoring. Trivial tests are unlikely to reveal any regressions, though, because there’s not much room for a mistake in the underlying code.

在重言式测试中采用极端结果的琐碎测试。它们不测试任何东西,因为它们的设置方式总是通过或包含语义上无意义的断言。

Trivial tests taken to an extreme result in tautology tests. They don’t test anything because they are set up in such a way that they always pass or contain semantically meaningless assertions.

4.7显示了简单测试的位置。他们对重构有很好的抵抗力并提供快速反馈,但他们不能保护您免受回归。

Figure 4.7 shows where trivial tests stand. They have good resistance to refactoring and provide fast feedback, but they don’t protect you from regressions.

图 4.7。琐碎的测试对重构有很好的抵抗力,并且它们提供快速的反馈,但是这样的测试并不能保护你免受回归。

Figure 4.7. Trivial tests have good resistance to refactoring, and they provide fast feedback, but such tests don’t protect you from regressions.

CH04 图 7 琐碎的测试

4.4.4 极端案例#3:脆性测试

4.4.4  Extreme case #3: Brittle tests

类似地,很容易编写一个运行速度快并且很有可能捕获回归但会出现大量误报的测试。这样的测试被称为脆性测试:它经不起重构,无论底层功能是否被破坏都会变成红色。

Similarly, it’s pretty easy to write a test that runs fast and has a good chance of catching a regression but does so with a lot of false positives. Such a test is called a brittle test: it can’t withstand a refactoring and will turn red regardless of whether the underlying functionality is broken.

您已经在清单4.2中看到了一个脆弱测试的例子。这是另一个:

You already saw an example of a brittle test in listing 4.2. Here’s another one:

清单 4.6。测试验证执行了什么 SQL 语句

Listing 4.6. Test verifying what SQL statement is executed

公共类 UserRepository
{
    公共用户 GetById(int id)
    {
        /* ... */
    }

    公共字符串 LastExecutedSqlStatement { 得到; 放; }
}

[事实]
public void GetById_executes_correct_SQL_code()
{
    var sut = new UserRepository();

    用户 user = sut.GetById(5);

    断言.等于(
        "SELECT * FROM dbo.[User] WHERE UserID = 5",
        sut.LastExecutedSqlStatement);
}
public class UserRepository
{
    public User GetById(int id)
    {
        /* ... */
    }

    public string LastExecutedSqlStatement { get; set; }
}

[Fact]
public void GetById_executes_correct_SQL_code()
{
    var sut = new UserRepository();

    User user = sut.GetById(5);

    Assert.Equal(
        "SELECT * FROM dbo.[User] WHERE UserID = 5",
        sut.LastExecutedSqlStatement);
}

此测试确保UserRepository类在从数据库中获取用户时生成正确的 SQL 语句。这个测试能发现错误吗?它可以。例如,开发人员可能会搞乱 SQL 代码生成并错误地使用IDinstead of UserID,测试将通过引发失败来指出这一点。但是这个测试对重构有很好的抵抗力吗?绝对不。以下是导致相同结果的 SQL 语句的不同变体:

This test makes sure the UserRepository class generates a correct SQL statement when fetching a user from the database. Can this test catch a bug? It can. For example, a developer can mess up the SQL code generation and mistakenly use ID instead of UserID, and the test will point that out by raising a failure. But does this test have good resistance to refactoring? Absolutely not. Here are different variations of the SQL statement that lead to the same result:

SELECT * FROM dbo.[User] WHERE UserID = 5
SELECT * FROM dbo.User WHERE UserID = 5
从 dbo 选择用户 ID、姓名、电子邮件。[用户] WHERE UserID = 5
SELECT * FROM dbo.[User] WHERE UserID = @UserID
SELECT * FROM dbo.[User] WHERE UserID = 5
SELECT * FROM dbo.User WHERE UserID = 5
SELECT UserID, Name, Email FROM dbo.[User] WHERE UserID = 5
SELECT * FROM dbo.[User] WHERE UserID = @UserID

如果将 SQL 脚本更改为任何这些变体,清单4.7中的测试将变为红色,即使功能本身将保持运行。这又是一个将测试耦合到 SUT 的内部实现细节的示例。测试的重点是如何而不是什么,因此根植于 SUT 的实现细节,防止任何进一步的重构。

The test in listing 4.7 will turn red if you change the SQL script to any these variations, even though the functionality itself will remain operational. This is once again an example of coupling the test to the SUT’s internal implementation details. The test is focusing on hows instead of whats and thus ingrains the SUT’s implementation details, preventing any further refactoring.

4.8显示脆性测试属于第三个桶。这样的测试运行速度很快,可以很好地防止回归,但对重构几乎没有抵抗力。

Figure 4.8 shows that brittle tests fall into the third bucket. Such tests run fast and provide good protection against regressions but have little resistance to refactoring.

图 4.8。脆弱性测试运行速度很快,并且可以很好地防止回归,但它们对重构的抵抗力很小。

Figure 4.8. Brittle tests run fast, and they provide good protection against regressions, but they have little resistance to refactoring.

CH04 FIG 8 脆性测试

4.4.5 寻找理想的测试:结果

4.4.5  In search of an ideal test: The results

一个好的单元测试的前三个属性(防止回归抗重构快速反馈)是相互排斥的。虽然很容易想出一个最大化这三个属性中的两个的测试,但您只能以牺牲第三个为代价来做到这一点。尽管如此,由于乘法规则,这样的测试将具有接近于零的值。不幸的是,不可能创建一个在所有三个属性上都获得完美分数的理想测试(图4.9)。

The first three attributes of a good unit test (protection against regressions, resistance to refactoring, and fast feedback) are mutually exclusive. While it’s quite easy to come up with a test that maximizes two out of these three attributes, you can only do that at the expense of the third. Still, such a test would have a close-to-zero value due to the multiplication rule. Unfortunately, it’s impossible to create an ideal test that has a perfect score in all three attributes (figure 4.9).

图 4.9。不可能创建一个在所有三个属性上都获得完美分数的理想测试。

Figure 4.9. It’s impossible to create an ideal test that would have a perfect score in all three attributes.

CH04 图9理想

第四个属性,可维护性,与前三个无关,端到端测试除外。端到端测试通常规模较大,因为需要设置此类测试所涉及的所有依赖项。他们还需要额外的努力来保持这些依赖项的运行。因此,端到端测试在维护成本方面往往更加昂贵。

The fourth attribute, maintainability, is not correlated to the first three, with the exception of end-to-end tests. End-to-end tests are normally larger in size because of the necessity to set up all the dependencies such tests reach out to. They also require additional effort to keep those dependencies operational. Hence end-to-end tests tend to be more expensive in terms of maintenance costs.

很难在好的测试的属性之间保持平衡。测试不可能在前三个类别中的每一个类别中都获得最高分,并且您还必须关注可维护性方面,以便测试保持合理的简短。因此,你必须做出取舍。此外,您应该以没有特定属性变为零的方式进行这些权衡。牺牲必须是局部的和战略性的。

It’s hard to keep a balance between the attributes of a good test. A test can’t have the maximum score in each of the first three categories, and you also have to keep an eye on the maintainability aspect so the test remains reasonably short and simple. Therefore, you have to make trade-offs. Moreover, you should make those trade-offs in such a way that no particular attribute turns to zero. The sacrifices have to be partial and strategic.

这些牺牲应该是什么样的?由于防止回归抵制重构快速反馈的相互排斥性,您可能认为最好的策略是对每一个做出一点让步:刚好足以为所有三个属性腾出空间。

What should those sacrifices look like? Because of the mutual exclusiveness of protection against regressions, resistance to refactoring, and fast feedback, you may think that the best strategy is to concede a little bit of each: just enough to make room for all three attributes.

但实际上,抵制重构是没有商量余地的。如果您的测试保持相当快并且您不求助于端到端测试的排他性使用,那么您的目标应该是尽可能多地获得它。然后,权衡归结为您的测试在指出错误方面有多好和它们这样做的速度之间的选择:也就是说,在防止回归快速反馈之间进行选择。您可以将此选择视为一个滑块,可以在防止回归快速反馈之间自由移动。你在一个属性上获得的越多,你在另一个属性上的损失就越多(见图4.10)。

In reality, though, resistance to refactoring is non-negotiable. You should aim at gaining as much of it as you can, provided that your tests remain reasonably quick and you don’t resort to the exclusive use of end-to-end tests. The trade-off, then, comes down to the choice between how good your tests are at pointing out bugs and how fast they do that: that is, between protection against regressions and fast feedback. You can view this choice as a slider that can be freely moved between protection against regressions and fast feedback. The more you gain in one attribute, the more you lose on the other (see figure 4.10).

图 4.10。最好的测试表现出最大的可维护性和对重构的抵抗力;始终尝试最大化这两个属性。权衡归结为防止回归和快速反馈之间的选择。

Figure 4.10. The best tests exhibit maximum maintainability and resistance to refactoring; always try to max out these two attributes. The trade-off comes down to the choice between protection against regressions and fast feedback.

CH04 图10选择

拒绝重构的原因是不可协商的,因为测试是否具有此属性主要是二元选择:测试要么拒绝重构,要么不拒绝。中间几乎没有中间阶段。因此,您不能只承认对重构的一点抵制:您将不得不失去它。另一方面,防止回归快速反馈的指标更具可塑性。在下一节中,您将看到当您选择一个而不是另一个时可以进行什么样的权衡。

The reason resistance to refactoring is non-negotiable is that whether a test possesses this attribute is mostly a binary choice: the test either has resistance to refactoring or it doesn’t. There are almost no intermediate stages in between. Thus you can’t concede just a little resistance to refactoring: you’ll have to lose it all. On the other hand, the metrics of protection against regressions and fast feedback are more malleable. You will see in the next section what kind of trade-offs are possible when you choose one over the other.

 

 

[提示] 提示

消除测试中的脆弱性(误报)是通往健壮测试套件的首要任务。

Eradicating brittleness (false positives) in tests is the first priority on the path to a robust test suite.

CAP定理

The CAP theorem

一个好的单元测试的前三个属性之间的权衡类似于 CAP 定理。CAP 定理指出,分布式数据存储不可能同时提供以下三种保证中的两种以上:

The trade-off between the first three attributes of a good unit test is similar to the CAP theorem. The CAP theorem states that it is impossible for a distributed data store to simultaneously provide more than two of the following three guarantees:

  • 一致性,这意味着每次读取都会收到最近的写入或错误。

  • Consistency, which means every read receives the most recent write or an error.

  • 可用性,这意味着每个请求都会收到响应(除了影响系统中所有节点的中断)。

  • Availability, which means every request receives a response (apart from outages that affect all nodes in the system).

  • Partition tolerance,这意味着系统在网络分区(网络节点之间失去连接)的情况下继续运行。

  • Partition tolerance, which means the system continues to operate despite network partitioning (losing connection between network nodes).

相似之处有两点:

The similarity is two-fold:

  • 首先,存在三取二的权衡。

  • First, there is the two-out-of-three trade-off.

  • 其次,大规模分布式系统中的分区容忍组件也是不可协商的。像亚马逊网站这样的大型应用程序无法在一台机器上运行。以牺牲分区容错性为代价来偏好一致性可用性的选项根本不在桌面上——Amazon 有太多数据无法存储在单个服务器上,无论该服务器有多大。

  • Second, the partition tolerance component in large-scale distributed systems is also non-negotiable. A large application such as, for example, the Amazon website can’t operate on a single machine. The option of preferring consistency and availability at the expense of partition tolerance simply isn’t on the table — Amazon has too much data to store on a single server, however big that server is.

那么,选择也归结为一致性可用性之间的权衡。在系统的某些部分,最好让步一点一致性以获得更多可用性。例如,在显示产品目录时,如果目录的某些部分已过时,通常是没有问题的。在这种情况下,可用性具有更高的优先级。另一方面,在更新产品描述时,一致性比可用性更重要:网络节点必须就该描述的最新版本达成共识,以避免合并冲突。

The choice, then, also boils down to a trade-off between consistency and availability. In some parts of the system, it’s preferable to concede a little consistency to gain more availability. For example, when displaying a product catalog, it’s generally fine if some parts of the catalog are out of date. Availability is of higher priority in this scenario. On the other hand, when updating a product description, consistency is more important than availability: network nodes must have a consensus on what the most recent version of that description is, in order to avoid merge conflicts.

4.5 探索众所周知的测试自动化概念

4.5  Exploring well-known test automation concepts

前面显示的良好单元测试的四个属性是基础。所有现有的、众所周知的测试自动化概念都可以追溯到这四个属性。在本节中,我们将研究两个这样的概念:测试金字塔和白盒与黑盒测试。

The four attributes of a good unit test shown earlier are foundational. All existing, well-known test automation concepts can be traced back to these four attributes. In this section, we’ll look at two such concepts: the Test Pyramid and white-box versus black-box testing.

4.5.1 分解测试金字塔

4.5.1  Breaking down the Test Pyramid

测试金字塔是一个提倡在测试套件中按一定比例的不同类型测试的概念(图4.11):

The Test Pyramid is a concept that advocates for a certain ratio of different types of tests in the test suite (figure 4.11):

  • 单元测试

  • Unit tests

  • 集成测试

  • Integration tests

  • 端到端测试

  • End-to-end tests

测试金字塔通常在视觉上表示为包含这三种类型测试的金字塔。金字塔层的宽度是指套件中特定类型测试的普遍性。层越宽,测试计数越大。层的高度衡量这些测试与模拟最终用户行为的接近程度。端到端测试位于顶部——它们最接近于模仿用户体验。

The Test Pyramid is often represented visually as a pyramid with those three types of tests in it. The width of the pyramid layers refers to the prevalence of a particular type of test in the suite. The wider the layer, the greater the test count. The height of the layer is a measure of how close these tests are to emulating the end user’s behavior. End-to-end tests are at the top — they are the closest to imitating the user experience.

图 4.11。测试金字塔提倡一定比例的单元、集成和端到端测试。

Figure 4.11. The Test Pyramid advocates for a certain ratio of unit, integration, and end-to-end tests.

CH04 图11 测试金字塔

金字塔中不同类型的测试在快速反馈防止回归之间的权衡中做出不同的选择。较高金字塔层的测试有利于防止回归,而较低层则强调执行速度(图4.12)。

Different types of tests in the pyramid make different choices in the trade-off between fast feedback and protection against regressions. Tests in higher pyramid layers favor protection against regressions, while lower layers emphasize execution speed (figure 4.12).

图 4.12。金字塔中不同类型的测试在快速反馈和防止回归之间做出不同的选择。端到端测试有利于防止回归,单元测试强调快速反馈,集成测试位于中间。

Figure 4.12. Different types of tests in the pyramid make different choices between fast feedback and protection against regressions. End-to-end tests favor protection against regressions, unit tests emphasize fast feedback, and integration tests lie in the middle.

CH04 图12金字塔选择

请注意,这两层都没有放弃对重构的抵抗。自然地,端到端测试和集成测试在此指标上的得分高于单元测试,但这只是作为与生产代码更加分离的副作用。尽管如此,即使是单元测试也不应承认对重构的抵制。所有测试都应旨在产生尽可能少的误报,即使直接使用生产代码也是如此。如何做到这一点是下一章的主题。

Notice that neither layer gives up resistance to refactoring. Naturally, end-to-end and integration tests score higher on this metric than unit tests, but only as a side effect of being more detached from the production code. Still, even unit tests should not concede resistance to refactoring. All tests should aim at producing as few false positives as possible, even when working directly with the production code. How to do that is the topic of the next chapter.

每个团队和项目的测试类型之间的确切组合将有所不同。但总的来说,它应该保持金字塔形状:端到端测试应该是少数;单元测试,大多数;和中间某处的集成测试。

The exact mix between types of tests will be different for each team and project. But in general, it should retain the pyramid shape: end-to-end tests should be the minority; unit tests, the majority; and integration tests somewhere in the middle.

端到端测试占少数的原因再次是 4.4 节中描述的乘法规则。端到端测试在快速反馈指标上得分极低。它们也缺乏可维护性:它们的规模往往更大,需要额外的努力来维护所涉及的进程外依赖性。因此,端到端测试只有在应用于最关键的功能时才有意义——您永远不想看到任何错误的功能——并且只有在您无法通过单元或集成获得相同程度的保护时才有意义测试。对任何其他事物使用端到端测试不应超过您的最低要求值阈值。单元测试通常更平衡,因此通常会有更多单元测试。

The reason end-to-end tests are the minority is, again, the multiplication rule described in section 4.4. End-to-end tests score extremely low on the metric of fast feedback. They also lack maintainability: they tend to be larger in size and require additional effort to maintain the involved out-of-process dependencies. Thus, end-to-end tests only make sense when applied to the most critical functionality — features in which you don’t ever want to see any bugs — and only when you can’t get the same degree of protection with unit or integration tests. The use of end-to-end tests for anything else shouldn’t pass your minimum required value threshold. Unit tests are usually more balanced, and hence you normally have many more of them.

测试金字塔也有例外。例如,如果您的应用程序所做的只是基本的创建、读取、更新和删除 (CRUD) 操作,几乎没有业务规则或任何其他复杂性,那么您的测试“金字塔”很可能看起来像一个具有相同数量单元的矩形和集成测试,没有端到端测试。

There are exceptions to the Test Pyramid. For example, if all your application does is basic create, read, update, and delete (CRUD) operations with very few business rules or any other complexity, your test "pyramid" will most likely look like a rectangle with an equal number of unit and integration tests and no end-to-end tests.

单元测试在没有算法或业务复杂性的情况下用处不大——它们很快就会沦为琐碎的测试。同时,集成测试保留了它们的价值——验证代码如何与其他子系统(如数据库)集成工作仍然很重要,无论它多么简单。结果,您可能会得到更少的单元测试和更多的集成测试。在最简单的例子中,集成测试的数量甚至可能比单元测试的数量更多。

Unit tests are less useful in a setting without algorithmic or business complexity — they quickly descend into trivial tests. At the same time, integration tests retain their value — it’s still important to verify how code, however simple it is, works in integration with other subsystems, such as the database. As a result, you may end up with fewer unit tests and more integration tests. In the most trivial examples, the number of integration tests may even be greater than the number of unit tests.

测试金字塔的另一个例外是一个 API,它延伸到一个进程外依赖项——比如一个数据库。对于这样的应用程序,进行更多的端到端测试可能是一个可行的选择。由于没有用户界面,端到端测试运行得相当快。维护成本也不会太高,因为您只使用单一的外部依赖项,即数据库。基本上,端到端测试与此环境中的集成测试没有区别。唯一不同的是入口点:端到端测试要求将应用程序托管在某个地方以完全模拟最终用户,而集成测试通常在同一进程中托管应用程序。我们将回到第 8 章中的测试金字塔,届时我们将讨论集成测试。

Another exception to the Test Pyramid is an API that reaches out to a single out-of-process dependency — say, a database. Having more end-to-end tests may be a viable option for such an application. Since there’s no user interface, end-to-end tests will run reasonably fast. The maintenance costs won’t be too high, either, because you only work with the single external dependency, the database. Basically, end-to-end tests are indistinguishable from integration tests in this environment. The only thing that differs is the entry point: end-to-end tests require the application to be hosted somewhere to fully emulate the end user, while integration tests normally host the application in the same process. We’ll get back to the Test Pyramid in chapter 8, when we’ll be talking about integration testing.

4.5.2 在黑盒和白盒测试之间进行选择

4.5.2   Choosing between black-box and white-box testing

另一个众所周知的测试自动化概念是黑盒测试与白盒测试。在本节中,我将展示何时使用这两种方法中的每一种:

The other well-known test automation concept is black-box versus white-box testing. In this section, I show when to use each of the two approaches:

  • 黑盒测试是一种在不知道系统内部结构的情况下检查系统功能的软件测试方法。此类测试通常围绕规范和要求构建:应用程序应该做什么,而不是如何做

  • Black-box testing is a method of software testing that examines the functionality of a system without knowing its internal structure. Such testing is normally built around specifications and requirements: what the application is supposed to do, rather than how it does it.

  • 白盒测试与此相反。这是一种验证应用程序内部工作的测试方法。测试源自源代码,而不是需求或规范。

  • White-box testing is the opposite of that. It’s a method of testing that verifies the application’s inner workings. The tests are derived from the source code, not requirements or specifications.

这两种方法各有利弊。白盒测试往往更彻底。通过分析源代码,您可以发现许多在仅依赖外部规范时可能会遗漏的错误。另一方面,白盒测试产生的测试通常很脆弱,因为它们往往与被测代码的特定实现紧密耦合。此类测试会产生许多误报,因此不符合重构阻力指标。它们通常也无法追溯到对业务人员有意义的行为,这强烈表明这些测试是脆弱的并且不会增加太多价值。黑盒测试提供了相反的优点和缺点(表4.1)。

There are pros and cons to both of these methods. White-box testing tends to be more thorough. By analyzing the source code, you can uncover a lot of errors that you may miss when relying solely on external specifications. On the other hand, tests resulting from white-box testing are often brittle, as they tend to tightly couple to the specific implementation of the code under test. Such tests produce many false positives and thus fall short on the metric of resistance to refactoring. They also often can’t be traced back to a behavior that is meaningful to a business person, which is a strong sign that these tests are fragile and don’t add much value. Black-box testing provides the opposite set of pros and cons (table 4.1).

表 4.1。白盒测试和黑盒测试的优缺点

Table 4.1. The pros and cons of white-box and black-box testing

  防止回归 抵制重构

白盒测试

White-box testing

好的

Good

坏的

Bad

黑盒测试

Black-box testing

坏的

Bad

好的

Good

您可能还记得第 4.4.5 节中提到的,您不能在重构阻力上妥协:测试要么具有重构阻力,要么没有。因此,默认选择黑盒测试而不是白盒测试。进行所有测试——无论是单元测试、集成测试还是端到端测试——将系统视为黑匣子,并验证对问题域有意义的行为。如果您无法将测试追溯到业务需求,则表明测试很脆弱。重组或删除此测试;不要让它按原样进入套房。唯一的例外是当测试涵盖具有高算法复杂性的实用程序代码时(第 7 章对此有更多介绍)。

As you may remember from section 4.4.5, you can’t compromise on resistance to refactoring: a test either possesses resistance to refactoring or it doesn’t. Therefore, choose black-box testing over white-box testing by default. Make all tests — be they unit, integration, or end-to-end — view the system as a black box and verify behavior meaningful to the problem domain. If you can’t trace a test back to a business requirement, it’s an indication of the test’s brittleness. Either restructure or delete this test; don’t let it into the suite as is. The only exception is when the test covers utility code with high algorithmic complexity (more on this in chapter 7).

请注意,尽管在编写测试时最好使用黑盒测试,但在分析测试时仍然可以使用白盒方法。使用代码覆盖工具查看哪些代码分支没有被执行,然后像对代码的内部结构一无所知一样转身测试它们。这种白盒和黑盒方法的组合效果最好。

Note that even though black-box testing is preferable when writing tests, you can still use the white-box method when analyzing the tests. Use code coverage tools to see which code branches are not exercised, but then turn around and test them as if you know nothing about the code’s internal structure. Such a combination of the white-box and black-box methods works best.

4.6 总结

4.6  Summary

  • 一个好的单元测试有四个基本属性,您可以使用它们来分析任何自动化测试,无论是单元测试、集成测试还是端到端测试:

    • 防止回归

    • 抵制重构

    • 快速反馈

    • 可维护性

  • A good unit test has four foundational attributes that you can use to analyze any automated test, whether unit, integration, or end-to-end:

    • Protection against regressions

    • Resistance to refactoring

    • Fast feedback

    • Maintainability

  • 防止回归是衡量测试在指示错误(回归)存在方面有多好。测试执行的代码越多(您的代码以及项目中使用的库和框架的代码),该测试揭示错误的可能性就越大。

  • Protection against regressions is a measure of how good the test is at indicating the presence of bugs (regressions). The more code the test executes (both your code and the code of libraries and frameworks used in the project), the higher the chance this test will reveal a bug.

  • 重构阻力是指测试在不产生误报的情况下维持应用程序代码重构的程度。

  • Resistance to refactoring is the degree to which a test can sustain application code refactoring without producing a false positive.

  • 误报是误报——表明测试失败的结果,而它涵盖的功能按预期工作。误报会对测试套件产生毁灭性的影响:

    • 它们削弱了您对代码中的问题做出反应的能力和意愿,因为您已经习惯了误报并不再关注它们。

    • 它们削弱了您对测试作为可靠安全网的看法,并导致对测试套件失去信任。

  • A false positive is a false alarm — a result indicating that the test fails, whereas the functionality it covers works as intended. False positives can have a devastating effect on the test suite:

    • They dilute your ability and willingness to react to problems in code, because you get accustomed to false alarms and stop paying attention to them.

    • They diminish your perception of tests as a reliable safety net and lead to losing trust in the test suite.

  • 误报是测试与被测系统的内部实现细节之间紧密耦合的结果。为避免这种耦合,测试必须验证 SUT 产生的最终结果,而不是验证它所采取的步骤。

  • False positives are a result of tight coupling between tests and the internal implementation details of the system under test. To avoid such coupling, the test must verify the end result the SUT produces, not the steps it took to do that.

  • 防止回归抵制重构有助于提高测试准确性。一个测试是准确的,因为它产生了一个强烈的信号(能够发现错误,防止回归的范围)和尽可能少的噪音(误报)(重构的阻力范围)。

  • Protection against regressions and resistance to refactoring contribute to test accuracy. A test is accurate insofar as it generates a strong signal (is capable of finding bugs, the sphere of protection against regressions) with as little noise (false positives) as possible (the sphere of resistance to refactoring).

  • 误报在项目开始时并没有那么大的负面影响,但随着项目的发展它们变得越来越重要:与误报(未注意到的错误)一样重要。

  • False positives don’t have as much of a negative effect in the beginning of the project, but they become increasingly important as the project grows: as important as false negatives (unnoticed bugs).

  • 快速反馈衡量测试执行的速度。

  • Fast feedback is a measure of how quickly the test executes.

  • 可维护性由两部分组成:

    • 理解测试有多难。测试越小,它的可读性就越高。

    • 运行测试有多困难。测试涉及的进程外依赖项越少,就越容易保持它们的可操作性。

  • Maintainability consists of two components:

    • How hard it is to understand the test. The smaller the test, the more readable it is.

    • How hard it is to run the test. The fewer out-of-process dependencies the test reaches out to, the easier it is to keep them operational.

  • 测试的价值估计是测试在四个属性中的每一个中获得的分数的乘积。如果测试在其中一个属性中为零,则其值也变为零。

  • A test’s value estimate is the product of scores the test gets in each of the four attributes. If the test gets zero in one of the attributes, its value turns to zero as well.

  • 不可能创建一个在所有四个属性中都获得最高分的测试,因为前三个属性—— 防止回归抵抗重构快速反馈 ——是相互排斥的。该测试只能最大化三个中的两个。

  • It’s impossible to create a test that gets the maximum score in all four attributes, because the first three — protection against regressions, resistance to refactoring, and fast feedback — are mutually exclusive. The test can only maximize two out of the three.

  • 抵制重构是没有商量余地的,因为测试是否具有此属性主要是二元选择:测试要么抵制重构,要么不抵制。属性之间的权衡归结为防止回归快速反馈之间的选择。

  • Resistance to refactoring is non-negotiable because whether a test possess this attribute is mostly a binary choice: the test either has resistance to refactoring or it doesn’t. The trade-off between the attributes comes down to the choice between protection against regressions and fast feedback.

  • 测试金字塔提倡单元测试、集成测试和端到端测试按一定比例进行:端到端测试应该占少数,单元测试占多数,集成测试居中。

  • The Test Pyramid advocates for a certain ratio of unit, integration, and end-to-end tests: end-to-end tests should be in the minority, unit tests in the majority, and integration tests somewhere in the middle.

  • 金字塔中不同类型的测试在快速反馈防止回归之间做出不同的选择。端到端测试有利于防止回归,而单元测试有利于快速反馈

  • Different types of tests in the pyramid make different choices between fast feedback and protection against regressions. End-to-end tests favor protection against regressions, while unit tests favor fast feedback.

  • 编写测试时使用黑盒测试方法。分析测试时使用白盒方法。

  • Use the black-box testing method when writing tests. Use the white-box method when analyzing the tests.

5

5

模拟和测试脆弱性

Mocks and test fragility

本章涵盖:

This chapter covers:

  • 区分模拟和存根

  • Differentiating mocks from stubs

  • 定义可观察的行为和实现细节

  • Defining observable behavior and implementation details

  • 了解模拟和测试脆弱性之间的关系

  • Understanding the relationship between mocks and test fragility

  • 在不影响重构阻力的情况下使用模拟

  • Using mocks without compromising resistance to refactoring

第 4 章介绍了一个参考框架,您可以使用它来分析特定测试和单元测试方法。在本章中,您将看到该参照系在起作用;我们将用它来剖析模拟的主题。

Chapter 4 introduced a frame of reference that you can use to analyze specific tests and unit testing approaches. In this chapter, you’ll see that frame of reference in action; we’ll use it to dissect the topic of mocks.

在测试中使用模拟是一个有争议的话题。有些人认为模拟是一个很好的工具,并将其应用于他们的大多数测试中。其他人声称模拟会导致测试脆弱性,并尽量不使用它们。俗话说,真相介于两者之间。在本章中,我将证明,实际上,模拟通常会导致脆弱的测试——缺乏重构阻力指标的测试。但在某些情况下,模拟仍然适用,甚至更可取。

The use of mocks in tests is a controversial subject. Some people argue that mocks are a great tool and apply them in most of their tests. Others claim that mocks lead to test fragility and try not to use them at all. As the saying goes, the truth lies somewhere in between. In this chapter, I’ll show that, indeed, mocks often result in fragile tests — tests that lack the metric of resistance to refactoring. But there are still cases where mocking is applicable and even preferable.

本章大量借鉴了第 2 章关于伦敦单元测试与经典单元测试学派的讨论。简而言之,学派之间的分歧源于他们对测试隔离问题的看法。伦敦学派提倡将被测试的代码片段彼此隔离,并对除不可变依赖之外的所有代码使用测试替身来执行这种隔离。

This chapter draws heavily on the discussion about the London versus classical schools of unit testing from chapter 2. In short, the disagreement between the schools stems from their views on the test isolation issue. The London school advocates isolating pieces of code under test from each other and using test doubles for all but immutable dependencies to perform such isolation.

古典学派主张将单元测试本身隔离开来,以便它们可以并行运行。这所学校仅对测试之间共享的依赖项使用测试替身。

The classical school stands for isolating unit tests themselves so that they can be run in parallel. This school uses test doubles only for dependencies that are shared between tests.

模拟和测试脆弱性之间有着深刻且几乎不可避免的联系。在接下来的几节中,我将逐步奠定基础,让您了解为什么存在这种联系。您还将学习如何使用模拟,这样它们就不会损害测试对重构的抵抗力

There’s a deep and almost inevitable connection between mocks and test fragility. In the next several sections, I will gradually lay down the foundation for you to see why that connection exists. You will also learn how to use mocks so that they don’t compromise a test’s resistance to refactoring.

5.1 区分模拟和存根

5.1  Differentiating mocks from stubs

在第 2 章中,我简要提到模拟是一个测试替身,它允许您检查被测系统 (SUT) 与其协作者之间的交互。还有另一种类型的测试替身:存根。让我们仔细看看模拟是什么以及它与存根有何不同。

In chapter 2, I briefly mentioned that a mock is a test double that allows you to examine interactions between the system under test (SUT) and its collaborators. There’s another type of test double: a stub. Let’s take a closer look at what a mock is and how it is different from a stub.

5.1.1 测试替身的类型

5.1.1  The types of test doubles

测试替身是一个包罗万象的术语,描述了测试中各种非生产就绪的虚假依赖项。该术语来自电影中替身的概念。测试替身的主要用途是方便测试;它们被传递给被测系统而不是真正的依赖关系,这可能很难设置或维护。

A test double is an overarching term that describes all kinds of non-production-ready, fake dependencies in tests. The term comes from the notion of a stunt double in a movie. The major use of test doubles is to facilitate testing; they are passed to the system under test instead of real dependencies, which could be hard to set up or maintain.

根据 Gerard Meszaros 的说法,测试替身有五种变体:dummystubspymockfake[2]这样的多样性看起来令人生畏,但实际上,它们都可以归为两种类型:模拟和存根(图5.1)。

According to Gerard Meszaros, there are five variations of test doubles: dummy, stub, spy, mock, and fake.[2] Such a variety can look intimidating, but in reality, they can all be grouped together into just two types: mocks and stubs (figure 5.1).

图 5.1。测试替身的所有变体都可以分为两种类型:模拟和存根。

Figure 5.1. All variations of test doubles can be categorized into two types: mocks and stubs.

CH05 图 1 模拟和存根

这两种类型之间的区别归结为以下几点:

The difference between these two types boils down to the following:

  • 模拟有助于模拟和检查结果交互。这些交互是 SUT 对其依赖项进行调用以更改其状态。

  • Mocks help to emulate and examine outcoming interactions. These interactions are calls the SUT makes to its dependencies to change their state.

  • 存根有助于模拟传入的交互。这些交互是 SUT 对其依赖项进行调用以获取输入数据(图5.2)。

  • Stubs help to emulate incoming interactions. These interactions are calls the SUT makes to its dependencies to get input data (figure 5.2).

图 5.2。发送电子邮件是一种输出交互:一种会在 SMTP 服务器中产生副作用的交互。模拟这种交互的测试替身是mock。从数据库中检索数据是传入交互;它不会产生副作用。相应的测试替身是一个存根

Figure 5.2. Sending an email is an outcoming interaction: an interaction that results in a side effect in the SMTP server. A test double emulating such an interaction is a mock. Retrieving data from the database is an incoming interaction; it doesn’t result in a side effect. The corresponding test double is a stub.

CH05 图2 传入传出

五个变体之间的所有其他差异都是无关紧要的实现细节。例如,间谍充当与模拟相同的角色。区别在于间谍是手动编写的,而模拟是在模拟框架的帮助下创建的。有时人们将间谍称为手写模拟

All other differences between the five variations are insignificant implementation details. For example, spies serve the same role as mocks. The distinction is that spies are written manually, whereas mocks are created with the help of a mocking framework. Sometimes people refer to spies as handwritten mocks.

另一方面,stub、dummy 和 fake 之间的区别在于它们的智能程度。哑是一个简单的硬编码值,例如空值或虚构的字符串。它用于满足 SUT 的方法签名,不参与生成最终结果。存根更为复杂这是一个完全成熟的依赖项,您可以将其配置为针对不同的场景返回不同的值。最后,在大多数情况下,伪造与存根相同。不同之处在于其创建的基本原理:通常实施伪造以替换尚不存在的依赖项。

On the other hand, the difference between a stub, a dummy, and a fake is in how intelligent they are. A dummy is a simple, hard-coded value such as a null value or a made-up string. It’s used to satisfy the SUT’s method signature and doesn’t participate in producing the final outcome. A stub is more sophisticated. It’s a fully fledged dependency that you configure to return different values for different scenarios. Finally, a fake is the same as a stub for most purposes. The difference is in the rationale for its creation: a fake is usually implemented to replace a dependency that doesn’t yet exist.

请注意模拟和存根之间的区别(除了输出交互与传入交互之外)。模拟有助于模拟和检查SUT 及其依赖项之间的交互,而存根仅有助于模拟这些交互。这是一个重要的区别。你很快就会明白为什么。

Notice the difference between mocks and stubs (aside from outcoming versus incoming interactions). Mocks help to emulate and examine interactions between the SUT and its dependencies, while stubs only help to emulate those interactions. This is an important distinction. You will see why shortly.

5.1.2 Mock(工具)与 mock(测试替身)

5.1.2  Mock (the tool) vs. mock (the test double)

mock这个词用的太多了,在不同的情况下可能有不同的意思。我在第 2 章提到过,人们经常使用这个术语来表示任何测试替身,而模拟只是测试替身的一个子集。但是mock一词还有另一个含义。您也可以将模拟库中的类称为模拟。这些类可以帮助您创建实际的模拟,但它们本身并不是模拟。以下清单显示了一个示例。

The term mock is overloaded and can mean different things in different circumstances. I mentioned in chapter 2 that people often use this term to mean any test double, whereas mocks are only a subset of test doubles. But there’s another meaning for the term mock. You can refer to the classes from mocking libraries as mocks, too. These classes help you create actual mocks, but they themselves are not mocks per se. The following listing shows an example.

清单 5.1。使用Mock模拟库中的类创建模拟

Listing 5.1. Using the Mock class from a mocking library to create a mock

[事实]
public void Sending_a_greetings_email()
{
    var mock = new Mock<IEmailGateway>();    
    var sut = new Controller(mock.Object);

    sut.GreetUser("user@email.com");

    模拟。验证(                      
        x => x.SendGreetingsEmail(    
            "user@email.com"),       
        次.一次);                  
}
[Fact]
public void Sending_a_greetings_email()
{
    var mock = new Mock<IEmailGateway>();    
    var sut = new Controller(mock.Object);

    sut.GreetUser("user@email.com");

    mock.Verify(                     
        x => x.SendGreetingsEmail(   
            "user@email.com"),       
        Times.Once);                 
}

使用模拟(工具)创建模拟(测试替身)

Uses a mock (the tool) to create a mock (the test double)

检查从 SUT 到测试替身的调用

Examines the call from the SUT to the test double

清单5.1中的测试使用了Mock我选择的模拟库 (Moq) 中的类。此类是一种工具,可让您创建测试替身 — 模拟。换句话说,类Mock(或Mock<IEmailGateway>)是模拟(工具),而该类的实例mock模拟(测试替身)。重要的是不要将模拟(工具)与模拟(测试替身)混为一谈,因为您可以使用模拟(工具)来创建两种类型的测试替身:模拟和存根。

The test in listing 5.1 uses the Mock class from the mocking library of my choice (Moq). This class is a tool that enables you to create a test double — a mock. In other words, the class Mock (or Mock<IEmailGateway>) is a mock (the tool), while the instance of that class, mock, is a mock (the test double). It’s important not to conflate a mock (the tool) with a mock (the test double) because you can use a mock (the tool) to create both types of test doubles: mocks and stubs.

以下清单中的测试也使用了该类Mock,但该类的实例不是模拟,而是存根。

The test in the following listing also uses the Mock class, but the instance of that class is not a mock, it’s a stub.

清单 5.2。使用Mock类创建存根

Listing 5.2. Using the Mock class to create a stub

[事实]
public void Creating_a_report()
{
    var stub = new Mock<IDatabase>();    
    stub.Setup(x => x.GetNumberOfUsers())    
        .返回(10);                       
    var sut = new Controller(stub.Object);

    报告 report = sut.CreateReport();

    Assert.Equal(10, report.NumberOfUsers);
}
[Fact]
public void Creating_a_report()
{
    var stub = new Mock<IDatabase>();   
    stub.Setup(x => x.GetNumberOfUsers())   
        .Returns(10);                       
    var sut = new Controller(stub.Object);

    Report report = sut.CreateReport();

    Assert.Equal(10, report.NumberOfUsers);
}

使用模拟(工具)创建存根

Uses a mock (the tool) to create a stub

设置预设答案

Sets up a canned answer

此测试替身模拟传入交互 - 为 SUT 提供输入数据的调用。另一方面,在前面的示例(清单5.1)中,对 的调用SendGreetingsEmail()是一个输出交互。它的唯一目的是产生副作用——发送电子邮件。

This test double emulates an incoming interaction — a call that provides the SUT with input data. On the other hand, in the previous example (listing 5.1), the call to SendGreetingsEmail() is an outcoming interaction. Its sole purpose is to incur a side effect — send an email.

5.1.3 不要断言与存根的交互

5.1.3  Don’t assert interactions with stubs

正如我在 5.1.1 节中提到的,模拟有助于模拟和检查SUT 及其依赖项之间的输出交互,而存根仅有助于模拟传入交互,而不是检查它们。两者之间的区别源于从不断言与存根交互的准则。从 SUT 到存根的调用不是 SUT 产生的最终结果的一部分。这样的调用只是产生最终结果的一种方法:存根提供输入,SUT 然后从中生成输出。

As I mentioned in section 5.1.1, mocks help to emulate and examine outcoming interactions between the SUT and its dependencies, while stubs only help to emulate incoming interactions, not examine them. The difference between the two stems from the guideline of never asserting interactions with stubs. A call from the SUT to a stub is not part of the end result the SUT produces. Such a call is only a means to produce the end result: a stub provides input from which the SUT then generates the output.

V03Asserting 与存根的交互是导致脆弱测试的常见反模式。

V03Asserting interactions with stubs is a common anti-pattern that leads to fragile tests.

您可能还记得第 4 章,避免误报并因此提高对测试中重构的抵抗力的唯一方法是让这些测试验证最终结果(理想情况下,这对非程序员应该有意义),而不是实现细节. 在清单5.1中,检查

As you might remember from chapter 4, the only way to avoid false positives and thus improve resistance to refactoring in tests is to make those tests verify the end result (which, ideally, should be meaningful to a non-programmer), not implementation details. In listing 5.1, the check

mock.Verify(x => x.SendGreetingsEmail("user@email.com"))
mock.Verify(x => x.SendGreetingsEmail("user@email.com"))

对应于实际结果,并且该结果对领域专家有意义:发送问候电子邮件是业务人员希望系统执行的操作。同时,清单5.2GetNumberOfUsers()中对 的调用根本不是结果。它是关于 SUT 如何收集报告创建所需数据的内部实现细节。因此,断言此调用会导致测试脆弱性:SUT 如何生成最终结果并不重要,只要该结果正确即可。以下清单显示了此类脆弱测试的示例。

corresponds to an actual outcome, and that outcome is meaningful to a domain expert: sending a greetings email is something business people would want the system to do. At the same time, the call to GetNumberOfUsers() in listing 5.2 is not an outcome at all. It’s an internal implementation detail regarding how the SUT gathers data necessary for the report creation. Therefore, asserting this call would lead to test fragility: it shouldn’t matter how the SUT generates the end result, as long as that result is correct. The following listing shows an example of such a brittle test.

清单 5.3。断言与存根的交互

Listing 5.3. Asserting an interaction with a stub

[事实]
public void Creating_a_report()
{
    var stub = new Mock<IDatabase>();
    stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
    var sut = new Controller(stub.Object);

    报告 report = sut.CreateReport();

    Assert.Equal(10, report.NumberOfUsers);
    stub.验证(                      
        x => x.GetNumberOfUsers(),   
        次.一次);                  
}
[Fact]
public void Creating_a_report()
{
    var stub = new Mock<IDatabase>();
    stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
    var sut = new Controller(stub.Object);

    Report report = sut.CreateReport();

    Assert.Equal(10, report.NumberOfUsers);
    stub.Verify(                     
        x => x.GetNumberOfUsers(),   
        Times.Once);                 
}

断言与存根的交互

Asserts the interaction with the stub

这种验证不属于最终结果的事物的做法也称为过度指定。最常见的是,过度指定发生在检查交互时。检查与存根的交互是一个很容易发现的缺陷,因为测试不应该检查与存根的任何交互。模拟是一个更复杂的主题:并非所有使用模拟都会导致测试脆弱性,但其中很多都会导致。您将在本章后面看到原因。

This practice of verifying things that aren’t part of the end result is also called overspecification. Most commonly, overspecification takes place when examining interactions. Checking for interactions with stubs is a flaw that’s quite easy to spot because tests shouldn’t check for any interactions with stubs. Mocks are a more complicated subject: not all uses of mocks lead to test fragility, but a lot of them do. You’ll see why later in this chapter.

5.1.4 同时使用模拟和存根

5.1.4  Using mocks and stubs together

有时您需要创建一个测试替身来展示模拟和存根的属性。例如,这是第 2 章中的一个测试,我用它来说明伦敦风格的单元测试。

Sometimes you need to create a test double that exhibits the properties of both a mock and a stub. For example, here’s a test from chapter 2 that I used to illustrate the London style of unit testing.

清单 5.4。 storeMock: 模拟和存根

Listing 5.4. storeMock: both a mock and a stub

[事实]
public void Purchase_fails_when_not_enough_inventory()
{
    var storeMock = new Mock<IStore>();
    storeMock                                
        .Setup(x => x.HasEnoughInventory(   
            产品.洗发水, 5))             
        .返回(假);                    
    var sut = new Customer();

    布尔成功 = sut.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    断言.False(成功);
    storeMock.验证(                                 
        x => x.RemoveInventory(Product.Shampoo, 5),  
        次.从不);                                 
}
[Fact]
public void Purchase_fails_when_not_enough_inventory()
{
    var storeMock = new Mock<IStore>();
    storeMock                               
        .Setup(x => x.HasEnoughInventory(   
            Product.Shampoo, 5))            
        .Returns(false);                    
    var sut = new Customer();

    bool success = sut.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    Assert.False(success);
    storeMock.Verify(                                
        x => x.RemoveInventory(Product.Shampoo, 5),  
        Times.Never);                                
}

设置预设答案

Sets up a canned answer

检查来自 SUT 的调用

Examines a call from the SUT

此测试用于storeMock两个目的:返回预设答案并验证 SUT 进行的方法调用。但是请注意,这是两种不同的方法:测试设置来自 的答案HasEnoughInventory(),然后验证对 的调用RemoveInventory()。因此,这里没有违反不断言与存根交互的规则。

This test uses storeMock for two purposes: it returns a canned answer and verifies a method call made by the SUT. Notice, though, that these are two different methods: the test sets up the answer from HasEnoughInventory() but then verifies the call to RemoveInventory(). Thus, the rule of not asserting interactions with stubs is not violated here.

当测试替身既是模拟又是存根时,它仍然被称为模拟,而不是存根。这主要是因为我们需要选择一个名字,但也因为模拟比存根更重要。

When a test double is both a mock and a stub, it’s still called a mock, not a stub. That’s mostly the case because we need to pick one name, but also because being a mock is a more important fact than a stub.

5.1.5 模拟和存根如何与命令和查询相关

5.1.5  How mocks and stubs relate to commands and queries

模拟和存根的概念与命令查询分离 (CQS) 原则相关。CQS 原则指出每个方法都应该是命令或查询,但不能同时是两者。如图5.3所示,命令是产生副作用并且不返回任何值 (return void) 的方法。副作用的示例包括改变对象的状态、更改文件系统中的文件等。查询与之相反——它们没有副作用并返回一个值。

The notions of mocks and stubs tie to the command query separation (CQS) principle. The CQS principle states that every method should be either a command or a query, but not both. As shown in figure 5.3, commands are methods that produce side effects and don’t return any value (return void). Examples of side effects include mutating an object’s state, changing a file in the file system, and so on. Queries are the opposite of that — they are side-effect free and return a value.

图 5.3。在命令查询分离(CQS)原则中,命令对应于模拟,而查询与存根一致。

Figure 5.3. In the command query separation (CQS) principle, commands correspond to mocks, while queries are consistent with stubs.

CH05 图3 CQS

要遵循此原则,请确保如果方法产生副作用,则该方法的返回类型为void. 如果该方法返回一个值,它必须保持无副作用。换句话说,提出问题不应该改变答案。保持如此清晰分离的代码变得更易于阅读。您可以通过查看方法的签名来判断方法的作用,而无需深入研究其实现细节。

To follow this principle, be sure that if a method produces a side effect, that method’s return type is void. And if the method returns a value, it must stay side-effect free. In other words, asking a question should not change the answer. Code that maintains such a clear separation becomes easier to read. You can tell what a method does just by looking at its signature, without diving into its implementation details.

当然,并不总是可以遵循 CQS 原则。总有一些方法既能产生副作用又能返回值。一个经典的例子是stack.Pop()。此方法既从堆栈中删除顶部元素,又将其返回给调用者。不过,尽可能遵守 CQS 原则是个好主意。

Of course, it’s not always possible to follow the CQS principle. There are always methods for which it makes sense to both incur a side effect and return a value. A classical example is stack.Pop(). This method both removes a top element from the stack and returns it to the caller. Still, it’s a good idea to adhere to the CQS principle whenever you can.

替代命令变成模拟的测试替身。同样,替代查询的测试替身是存根。再次查看清单5.15.2中的两个测试(我在这里展示了它们的相关部分):

Test doubles that substitute commands become mocks. Similarly, test doubles that substitute queries are stubs. Look at the two tests from listings 5.1 and 5.2 again (I’m showing their relevant parts here):

var mock = new Mock<IEmailGateway>();
mock.Verify(x => x.SendGreetingsEmail("user@email.com"));

var stub = new Mock<IDatabase>();
stub.Setup(x => x.GetNumberOfUsers()).Returns(10);
var mock = new Mock<IEmailGateway>();
mock.Verify(x => x.SendGreetingsEmail("user@email.com"));

var stub = new Mock<IDatabase>();
stub.Setup(x => x.GetNumberOfUsers()).Returns(10);

SendGreetingsEmail()是一个副作用是发送电子邮件的命令。替代此命令的测试替身是一个模拟。另一方面,GetNumberOfUsers()是一个返回值且不改变数据库状态的查询。相应的测试替身是存根。

SendGreetingsEmail() is a command whose side effect is sending an email. The test double that substitutes this command is a mock. On the other hand, GetNumberOfUsers() is a query that returns a value and doesn’t mutate the database state. The corresponding test double is a stub.



[2]请参阅xUnit 测试模式:重构测试代码(Addison-Wesley,2007 年)。

[2] See xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007).

5.2 可观察行为与实现细节

5.2  Observable behavior vs. implementation details

5.1 节展示了什么是 mock。解释模拟和测试脆弱性之间联系的下一步是深入研究导致这种脆弱性的原因。

Section 5.1 showed what a mock is. The next step on the way to explaining the connection between mocks and test fragility is diving into what causes such fragility.

您可能还记得第 4 章,测试脆弱性对应于良好单元测试的第二个属性:抗重构。(提醒一下,这四个属性是防止回归、抵抗重构、快速反馈和可维护性。)抵抗重构的指标是最重要的,因为单元测试是否拥有该指标主要是二元选择。因此,最好将此指标最大化到测试仍然处于单元测试领域并且不会过渡到端到端测试类别的程度。后者,尽管最擅长抵制重构,但通常更难维护。

As you might remember from chapter 4, test fragility corresponds to the second attribute of a good unit test: resistance to refactoring. (As a reminder, the four attributes are protection against regressions, resistance to refactoring, fast feedback, and maintainability.) The metric of resistance to refactoring is the most important because whether a unit test possesses this metric is mostly a binary choice. Thus, it’s good to max out this metric to the extent that the test still remains in the realm of unit testing and doesn’t transition to the category of end-to-end testing. The latter, despite being the best at resistance to refactoring, is generally much harder to maintain.

在第 4 章中,您还看到测试产生误报(因此无法抵抗重构)的主要原因是它们与代码的实现细节耦合。避免这种耦合的唯一方法是验证代码产生的最终结果(其可观察到的行为)并尽可能远离实现细节的测试。换句话说,测试必须关注是什么,而不是如何。那么,到底什么是实现细节,它与可观察的行为有何不同?

In chapter 4, you also saw that the main reason tests deliver false positives (and thus fail at resistance to refactoring) is because they couple to the code’s implementation details. The only way to avoid such coupling is to verify the end result the code produces (its observable behavior) and distance tests from implementation details as much as possible. In other words, tests must focus on the whats, not the hows. So, what exactly is an implementation detail, and how is it different from an observable behavior?

5.2.1 可观察行为与公共 API 不同

5.2.1  Observable behavior is not the same as a public API

所有生产代码都可以按两个维度进行分类:

All production code can be categorized along two dimensions:

  • 公共 API 与私有 API(其中 API 表示应用程序编程接口

  • Public API vs. private API (where API means application programming interface)

  • 可观察的行为与实现细节

  • Observable behavior vs. implementation details

这些维度中的类别不重叠。一个方法不能同时属于公共 API 和私有 API;它是一个或另一个。同样,代码要么是内部实现细节,要么是系统可观察行为的一部分,但不能同时是两者。

The categories in these dimensions don’t overlap. A method can’t belong to both a public and a private API; it’s either one or the other. Similarly, the code is either an internal implementation detail or part of the system’s observable behavior, but not both.

大多数编程语言都提供了一种简单的机制来区分代码库的公共 API 和私有 API。例如,在 C# 中,您可以用关键字标记类中的任何成员private,该成员将从客户端代码中隐藏,成为该类私有 API 的一部分。类也是如此:您可以使用privateorinternal关键字轻松地将它们设为私有。

Most programming languages provide a simple mechanism to differentiate between the code base’s public and private APIs. For example, in C#, you can mark any member in a class with the private keyword, and that member will be hidden from the client code, becoming part of the class’s private API. The same is true for classes: you can easily make them private by using the private or internal keyword.

可观察行为和内部实现细节之间的区别更加细微。要使一段代码成为系统可观察行为的一部分,它必须执行以下操作之一:

The distinction between observable behavior and internal implementation details is more nuanced. For a piece of code to be part of the system’s observable behavior, it has to do one of the following things:

  • 公开帮助客户实现其目标之一的操作。操作是一种执行计算或产生副作用或两者兼而有之的方法

  • Expose an operation that helps the client achieve one of its goals. An operation is a method that performs a calculation or incurs a side effect or both.

  • 公开一种有助于客户端实现其目标之一的状态。状态是系统的当前状态。

  • Expose a state that helps the client achieve one of its goals. State is the current condition of the system.

任何不做这两件事的代码都是实现细节

Any code that does neither of these two things is an implementation detail.

请注意,代码是否是可观察的行为取决于它的客户是谁以及该客户的目标是什么。为了成为可观察行为的一部分,代码需要与至少一个这样的目标有直接联系。根据代码所在的位置,客户端一词可以指代不同的事物。常见示例是来自相同代码库的客户端代码、外部应用程序或用户界面。

Notice that whether the code is observable behavior depends on who its client is and what the goals of that client are. In order to be a part of observable behavior, the code needs to have an immediate connection to at least one such goal. The word client can refer to different things depending on where the code resides. The common examples are client code from the same code base, an external application, or the user interface.

理想情况下,系统的公共 API 表面应与其可观察的行为一致,并且其所有实现细节都应隐藏在客户的视线之外。这样的系统有一个设计良好的API(图5.4)。

Ideally, the system’s public API surface should coincide with its observable behavior, and all its implementation details should be hidden from the eyes of the clients. Such a system has a well-designed API (figure 5.4).

图 5.4。在设计良好的API 中,可观察的行为与公共 API 一致,而所有实现细节都隐藏在私有 API 之后。

Figure 5.4. In a well-designed API, the observable behavior coincides with the public API, while all implementation details are hidden behind the private API.

CH05 图4精心制作的api

但是,系统的公共 API 通常会超出其可观察的行为,并开始公开实现细节。这样一个系统的实现细节会泄露到它的公共 API 表面(图5.5)。

Often, though, the system’s public API extends beyond its observable behavior and starts exposing implementation details. Such a system’s implementation details leak to its public API surface (figure 5.5).

图 5.5。当系统的公共 API 超出可观察的行为范围时,系统会泄露实现细节。

Figure 5.5. A system leaks implementation details when its public API extends beyond the observable behavior.

CH05 图5 漏电细节

5.2.2 泄漏实现细节:一个带有操作的例子

5.2.2  Leaking implementation details: An example with an operation

让我们看一下实现细节泄露到公共 API 的代码示例。清单5.5显示了一个User具有公共 API 的类,该 API 由两个成员组成:一个Name属性和一个NormalizeName()方法。该类还有一个不变量:用户名不得超过 50 个字符,否则应被截断。

Let’s take a look at examples of code whose implementation details leak to the public API. Listing 5.5 shows a User class with a public API that consists of two members: a Name property and a NormalizeName() method. The class also has an invariant: users' names must not exceed 50 characters and should be truncated otherwise.

清单 5.5。 User具有泄漏实现细节的类

Listing 5.5. User class with leaking implementation details

公共类用户
{
    公共字符串名称 { 得到; 放; }

    公共字符串 NormalizeName(字符串名称)
    {
        string result = (name ?? "").Trim();

        如果(结果。长度> 50)
            返回结果.Substring(0, 50);

        返回结果;
    }
}

公共类用户控制器
{
    public void RenameUser(int userId, string newName)
    {
        用户 user = GetUserFromDatabase(userId);

        string normalizedName = user.NormalizeName(newName);
        user.Name = normalizedName;

        保存用户到数据库(用户);
    }
}
public class User
{
    public string Name { get; set; }

    public string NormalizeName(string name)
    {
        string result = (name ?? "").Trim();

        if (result.Length > 50)
            return result.Substring(0, 50);

        return result;
    }
}

public class UserController
{
    public void RenameUser(int userId, string newName)
    {
        User user = GetUserFromDatabase(userId);

        string normalizedName = user.NormalizeName(newName);
        user.Name = normalizedName;

        SaveUserToDatabase(user);
    }
}

UserController是客户端代码。User它在其方法中使用该类RenameUser。您可能已经猜到,此方法的目标是更改用户名。

UserController is client code. It utilizes the User class in its RenameUser method. The goal of this method, as you have probably guessed, is to change a user’s name.

那么,为什么User的 API 设计不佳?再看看它的成员:Name属性和NormalizeName方法。两者都是公开的。因此,为了使类的 API 设计良好,这些成员应该是可观察行为的一部分。这反过来又要求他们做以下两件事之一(为方便起见,我在这里重复):

So, why isn’t User's API well-designed? Look at its members once again: the Name property and the NormalizeName method. Both of them are public. Therefore, in order for the class’s API to be well-designed, these members should be part of the observable behavior. This, in turn, requires them to do one of the following two things (which I’m repeating here for convenience):

  • 公开帮助客户实现其目标之一的操作。

  • Expose an operation that helps the client achieve one of its goals.

  • 公开一种有助于客户端实现其目标之一的状态。

  • Expose a state that helps the client achieve one of its goals.

只有该Name物业符合此要求。它公开了一个设置器,这是一个允许UserController实现更改用户名的目标的操作。该NormalizeName方法也是一种操作,但它与客户的目标没有直接联系。调用此方法的唯一原因UserController是为了满足 的不变性UserNormalizeName因此是泄漏到类的公共 API 的实现细节(图5.6)。

Only the Name property meets this requirement. It exposes a setter, which is an operation that allows UserController to achieve its goal of changing a user’s name. The NormalizeName method is also an operation, but it doesn’t have an immediate connection to the client’s goal. The only reason UserController calls this method is to satisfy the invariant of User. NormalizeName is therefore an implementation detail that leaks to the class’s public API (figure 5.6).

图 5.6。的 APIUser设计不佳:它公开了NormalizeName方法,这不是可观察行为的一部分。

Figure 5.6. The API of User is not well-designed: it exposes the NormalizeName method, which is not part of the observable behavior.

CH05 图6 example1之前

为了解决这个问题并使类的 API 设计良好,User需要隐藏NormalizeName()它并将其作为属性设置器的一部分在内部调用,而不依赖于客户端代码来这样做。下面的清单显示了这种方法。

To fix the situation and make the class’s API well-designed, User needs to hide NormalizeName() and call it internally as part of the property’s setter without relying on the client code to do so. The following listing shows this approach.

清单 5.6。User具有精心设计的 API的版本

Listing 5.6. A version of User with a well-designed API

公共类用户
{
    私有字符串_name;
    公共字符串名称
    {
        得到 => _名称;
        设置 => _name = NormalizeName(value);
    }

    私有字符串 NormalizeName(字符串名称)
    {
        string result = (name ?? "").Trim();

        如果(结果。长度> 50)
            返回结果.Substring(0, 50);

        返回结果;
    }
}

公共类用户控制器
{
    public void RenameUser(int userId, string newName)
    {
        用户 user = GetUserFromDatabase(userId);
        user.Name = newName;
        保存用户到数据库(用户);
    }
}
public class User
{
    private string _name;
    public string Name
    {
        get => _name;
        set => _name = NormalizeName(value);
    }

    private string NormalizeName(string name)
    {
        string result = (name ?? "").Trim();

        if (result.Length > 50)
            return result.Substring(0, 50);

        return result;
    }
}

public class UserController
{
    public void RenameUser(int userId, string newName)
    {
        User user = GetUserFromDatabase(userId);
        user.Name = newName;
        SaveUserToDatabase(user);
    }
}

User清单5.6中的 API设计良好:只有可观察的行为(Name属性)是公开的,而实现细节(方法NormalizeName)隐藏在私有 API 后面(图5.7)。

User's API in listing 5.6 is well-designed: only the observable behavior (the Name property) is made public, while the implementation details (the NormalizeName method) are hidden behind the private API (figure 5.7).

图 5.7。 User使用精心设计的 API。只有可观察的行为是公开的;实施细节现在是私有的。

Figure 5.7. User with a well-designed API. Only the observable behavior is public; the implementation details are now private.

CH05 图7 example1 后

V03严格来说,Name的 getter 也应该设为私有,因为它不被UserController. 但实际上,您几乎总是想回读您所做的更改。因此,在实际项目中,肯定会有另一种用例需要通过Namegetter 来查看用户的当前名称。

V03Strictly speaking, Name's getter should also be made private, because it’s not used by UserController. In reality, though, you almost always want to read back changes you make. Therefore, in a real project, there will certainly be another use case that requires seeing users' current names via Name's getter.

有一个很好的经验法则可以帮助您确定一个类是否泄露了它的实现细节。如果客户端为实现单个目标而必须在类上调用的操作数大于一个,则该类很可能会泄漏实现细节。理想情况下,任何个人目标都应该通过一次操作来实现。例如,在清单5.5UserController中,必须使用两个操作User

There’s a good rule of thumb that can help you determine whether a class leaks its implementation details. If the number of operations the client has to invoke on the class to achieve a single goal is greater than one, then that class is likely leaking implementation details. Ideally, any individual goal should be achieved with a single operation. In listing 5.5, for example, UserController has to use two operations from User:

string normalizedName = user.NormalizeName(newName);
user.Name = normalizedName;
string normalizedName = user.NormalizeName(newName);
user.Name = normalizedName;

重构后,操作数减少为一个:

After the refactoring, the number of operations has been reduced to one:

user.Name = newName;
user.Name = newName;

根据我的经验,这条经验法则适用于涉及业务逻辑的绝大多数情况。不过,很可能会有例外。尽管如此,请务必检查您的代码违反此规则的每种情况,以防止可能泄露实施细节。

In my experience, this rule of thumb holds true for the vast majority of cases where business logic is involved. There could very well be exceptions, though. Still, be sure to examine each situation where your code violates this rule for a potential leak of implementation details.

5.2.3 精心设计的API和封装

5.2.3  Well-designed API and encapsulation

维护设计良好的 API 与封装的概念有关。您可能还记得第 3 章,封装是保护您的代码免受不一致影响的行为,也称为不变违规。不变量是应该始终成立的条件。上一示例中的类User有一个这样的不变量:任何用户的名称都不能超过 50 个字符。

Maintaining a well-designed API relates to the notion of encapsulation. As you might recall from chapter 3, encapsulation is the act of protecting your code against inconsistencies, also known as invariant violations. An invariant is a condition that should be held true at all times. The User class from the previous example had one such invariant: no user could have a name that exceeded 50 characters.

公开实现细节与不变违规密切相关——前者通常会导致后者。原始版本不仅User泄露了其实现细节,而且也没有保持适当的封装。它允许客户端绕过不变量并为用户分配一个新名称,而无需首先对该名称进行规范化。

Exposing implementation details goes hand in hand with invariant violations — the former often leads to the latter. Not only did the original version of User leak its implementation details, but it also didn’t maintain proper encapsulation. It allowed the client to bypass the invariant and assign a new name to a user without normalizing that name first.

从长远来看,封装对于代码库的可维护性至关重要。原因是复杂性。代码复杂性是您在软件开发中将面临的最大挑战之一。代码库变得越复杂,使用起来就越困难,这反过来又会导致开发速度减慢并增加错误数量。

Encapsulation is crucial for code base maintainability in the long run. The reason is complexity. Code complexity is one of the biggest challenges you’ll face in software development. The more complex the code base becomes, the harder it is to work with, which, in turn, results in slowing down development speed and increasing the number of bugs.

没有封装,您就没有实用的方法来应对不断增加的代码复杂性。当代码的 API 没有指导您了解该代码允许做什么和不允许做什么时,您必须牢记大量信息,以确保您不会引入与新代码更改的不一致。这给编程的过程带来了额外的精神负担。尽可能多地减轻自己的负担。你不能相信自己总是做正确的事——所以,消除做错事的可能性。这样做的最佳方法是保持适当的封装,这样您的代码库甚至不会为您提供错误执行任何操作的选项。封装最终服务于与单元测试相同的目标:它使您的软件项目可持续增长。

Without encapsulation, you have no practical way to cope with ever-increasing code complexity. When the code’s API doesn’t guide you through what is and what isn’t allowed to do with that code, you have to keep a lot of information in mind to make sure you don’t introduce inconsistencies with new code changes. This brings an additional mental burden to the process of programming. Remove as much of that burden from yourself as possible. You cannot trust yourself to do the right thing all the time — so, eliminate the very possibility of doing the wrong thing. The best way to do so is to maintain proper encapsulation so that your code base doesn’t even provide an option for you to do anything incorrectly. Encapsulation ultimately serves the same goal as unit testing: it enables sustainable growth of your software project.

有一个类似的原则:告诉不要问它由 Martin Fowler ( https://martinfowler.com/bliki/TellDontAsk.html )创造,代表将数据与操作该数据的函数捆绑在一起。您可以将此原则视为封装实践的必然结果。代码封装是一个目标,而将数据和函数捆绑在一起以及隐藏实现细节是实现该目标的手段:

There’s a similar principle: tell-don’t-ask. It was coined by Martin Fowler (https://martinfowler.com/bliki/TellDontAsk.html) and stands for bundling data with the functions that operate on that data. You can view this principle as a corollary to the practice of encapsulation. Code encapsulation is a goal, whereas bundling data and functions together, as well as hiding implementation details, are the means to achieve that goal:

  • 隐藏实现细节可以帮助您从客户的眼中移除类的内部结构,从而降低破坏这些内部结构的风险。

  • Hiding implementation details helps you remove the class’s internals from the eyes of its clients, so there’s less risk of corrupting those internals.

  • 捆绑数据和操作有助于确保这些操作不违反类的不变量。

  • Bundling data and operations helps to make sure these operations don’t violate the class’s invariants.

5.2.4 泄漏实现细节:状态示例

5.2.4  Leaking implementation details: An example with state

清单5.5中显示的示例演示了一个操作(NormalizeName方法),该操作是泄漏到公共 API 的实现细节。让我们再看一个带有状态的例子。下面的清单包含MessageRenderer您在第 4 章中看到的类。它使用子呈现器的集合来生成消息的 HTML 表示形式,其中包含标题、正文和页脚。

The example shown in listing 5.5 demonstrated an operation (the NormalizeName method) that was an implementation detail leaking to the public API. Let’s also look at an example with state. The following listing contains the MessageRenderer class you saw in chapter 4. It uses a collection of sub-renderers to generate an HTML representation of a message containing a header, a body, and a footer.

清单 5.7。状态作为实现细节

Listing 5.7. State as an implementation detail

公共类 MessageRenderer : IRenderer
{
    public IReadOnlyList<IRenderer> SubRenderers { get; }

    公共 MessageRenderer()
    {
        SubRenderers = new List<IRenderer>
        {
            新的 HeaderRenderer(),
            新的身体渲染器(),
            新的 FooterRenderer()
        };
    }

    公共字符串渲染(消息消息)
    {
        返回子渲染器
            .Select(x => x.Render(message))
            .Aggregate("", (str1, str2) => str1 + str2);
    }
}
public class MessageRenderer : IRenderer
{
    public IReadOnlyList<IRenderer> SubRenderers { get; }

    public MessageRenderer()
    {
        SubRenderers = new List<IRenderer>
        {
            new HeaderRenderer(),
            new BodyRenderer(),
            new FooterRenderer()
        };
    }

    public string Render(Message message)
    {
        return SubRenderers
            .Select(x => x.Render(message))
            .Aggregate("", (str1, str2) => str1 + str2);
    }
}

子渲染器集合是公开的。但它是可观察行为的一部分吗?假设客户端的目标是呈现 HTML 消息,答案是否定的。这样的客户唯一需要的类成员是Render方法本身。因此SubRenderers也是一个泄漏的实现细节。

The sub-renderers collection is public. But is it part of observable behavior? Assuming that the client’s goal is to render an HTML message, the answer is no. The only class member such a client would need is the Render method itself. Thus SubRenderers is also a leaking implementation detail.

我再次提到这个例子是有原因的。您可能还记得,我用它来说明脆性测试。该测试之所以脆弱,恰恰是因为它与此实现细节相关联——它检查集合的组成。通过在该方法中重新定位测试来修复脆性Render。新版本的测试验证了生成的消息——客户端代码唯一关心的输出,即可观察到的行为。

I bring up this example again for a reason. As you may remember, I used it to illustrate a brittle test. That test was brittle precisely because it was tied to this implementation detail — it checked to see the collection’s composition. The brittleness was fixed by re-targeting the test at the Render method. The new version of the test verified the resulting message — the only output the client code cared about, the observable behavior.

如您所见,良好的单元测试与设计良好的 API 之间存在内在联系。通过将所有实现细节设为私有,您的测试别无选择,只能验证代码的可观察行为,这会自动提高它们对重构的抵抗力。

As you can see, there’s an intrinsic connection between good unit tests and a well-designed API. By making all implementation details private, you leave your tests no choice other than to verify the code’s observable behavior, which automatically improves their resistance to refactoring.

 

 

[提示] 提示

使 API 设计良好会自动改进单元测试。

Making the API well-designed automatically improves unit tests.

另一条准则源自设计良好的 API 的定义:您应该公开最少数量的操作和状态。只有直接帮助客户实现目标的代码才应该公开。其他一切都是实现细节,因此必须隐藏在私有 API 后面。

Another guideline flows from the definition of a well-designed API: you should expose the absolute minimum number of operations and state. Only code that directly helps clients achieve their goals should be made public. Everything else is implementation details and thus must be hidden behind the private API.

请注意,不存在泄漏可观察行为这样的问题,这与泄漏实现细节的问题是对称的。虽然您可以公开实现细节(客户端不应使用的方法或类),但您无法隐藏可观察到的行为。这样的方法或类将不再与客户目标有直接联系,因为客户将无法再直接使用它。因此,根据定义,此代码将不再是可观察行为的一部分。表5.1总结了这一切。

Note that there’s no such problem as leaking observable behavior, which would be symmetric to the problem of leaking implementation details. While you can expose an implementation detail (a method or a class that is not supposed to be used by the client), you can’t hide an observable behavior. Such a method or class would no longer have an immediate connection to the client goals, because the client wouldn’t be able to directly use it anymore. Thus, by definition, this code would cease to be part of observable behavior. Table 5.1 sums it all up.

表 5.1。代码的公开性和目的之间的关系。避免公开实施细节。

Table 5.1. The relationship between the code’s publicity and purpose. Avoid making implementation details public.

  可观察到的行为 实施细节

民众

Public

好的

Good

坏的

Bad

私人的

Private

不适用

N/A

好的

Good

5.3 mocks与测试脆弱性的关系

5.3  The relationship between mocks and test fragility

前面的部分定义了一个模拟,并展示了可观察行为和实现细节之间的区别。在本节中,您将了解六边形架构、内部和外部通信之间的区别,以及(最后!)模拟和测试脆弱性之间的关系。

The previous sections defined a mock and showed the difference between observable behavior and an implementation detail. In this section, you will learn about hexagonal architecture, the difference between internal and external communications, and (finally!) the relationship between mocks and test fragility.

5.3.1 定义六边形架构

5.3.1  Defining hexagonal architecture

一个典型的应用程序由两层组成,领域和应用服务,如图5.8所示。域位于图的中间,因为它是应用程序的中心部分。它包含业务逻辑:构建应用程序的基本功能。领域层及其业务逻辑将此应用程序与其他应用程序区分开来,并为组织提供竞争优势。

A typical application consists of two layers, domain and application services, as shown in figure 5.8. The domain layer resides in the middle of the diagram because it’s the central part of your application. It contains the business logic: the essential functionality your application is built for. The domain layer and its business logic differentiate this application from others and provide a competitive advantage for the organization.

图 5.8。典型的应用程序由领域层和应用程序服务层组成。领域层包含应用程序的业务逻辑;应用程序服务将该逻辑与业务用例联系起来。

Figure 5.8. A typical application consists of a domain layer and an application services layer. The domain layer contains the application’s business logic; application services tie that logic to business use cases.

CH05 图8六角

应用服务层位于领域层之上,协调该层与外部世界之间的通信。例如,如果您的应用程序是一个 RESTful API,则对该 API 的所有请求都会首先到达应用程序服务层。该层然后协调域类和进程外依赖项之间的工作。下面是应用程序服务的此类协调示例。它执行以下操作:

The application services layer sits on top of the domain layer and orchestrates communication between that layer and the external world. For example, if your application is a RESTful API, all requests to this API hit the application services layer first. This layer then coordinates the work between domain classes and out-of-process dependencies. Here’s an example of such coordination for the application service. It does the following:

  • 查询数据库并使用数据具体化域类实例

  • Queries the database and uses the data to materialize a domain class instance

  • 在该实例上调用一个操作

  • Invokes an operation on that instance

  • 将结果保存回数据库

  • Saves the results back to the database

应用服务层和领域层的组合形成一个六边形,它本身代表你的应用。它可以与其他应用程序交互,这些应用程序用自己的六边形表示(见图5.9)。这些其他应用程序可以是 SMTP 服务、第三方系统、消息总线等。一组相互作用的六边形构成了一个六边形建筑

The combination of the application services layer and the domain layer forms a hexagon, which itself represents your application. It can interact with other applications, which are represented with their own hexagons (see figure 5.9). These other applications could be an SMTP service, a third-party system, a message bus, and so on. A set of interacting hexagons makes up a hexagonal architecture.

图 5.9。六边形架构是一组交互应用程序——六边形。

Figure 5.9. A hexagonal architecture is a set of interacting applications — hexagons.

CH05 图9六边形

六边形建筑一词是由 Alistair Cockburn 提出的。其目的是强调三个重要的指导方针:

The term hexagonal architecture was introduced by Alistair Cockburn. Its purpose is to emphasize three important guidelines:

  • 域和应用程序服务层之间的关注点分离 ——业务逻辑是应用程序中最重要的部分。因此,领域层应该只对业务逻辑负责,并免除所有其他责任。这些责任,例如与外部应用程序通信和从数据库中检索数据,必须归于应用程序服务。相反,应用程序服务不应包含任何业务逻辑。他们的职责是通过将传入的请求转换为对域类的操作,然后持久化结果或将结果返回给调用者来调整域层。您可以将领域层视为应用程序领域知识的集合(操作方法) 和应用服务层作为一组业务用例 ( what-to's )。

  • The separation of concerns between the domain and application services layers — Business logic is the most important part of the application. Therefore, the domain layer should be accountable only for that business logic and exempted from all other responsibilities. Those responsibilities, such as communicating with external applications and retrieving data from the database, must be attributed to application services. Conversely, the application services shouldn’t contain any business logic. Their responsibility is to adapt the domain layer by translating the incoming requests into operations on domain classes and then persisting the results or returning them back to the caller. You can view the domain layer as a collection of the application’s domain knowledge (how-to’s) and the application services layer as a set of business use cases (what-to’s).

  • 应用程序内部的通信 ——六边形架构规定了一种单向的依赖流:从应用程序服务层到领域层。领域层内的类应该只相互依赖;它们不应该依赖于应用服务层的类。该指南源自上一指南。应用服务层和领域层的关注点分离意味着前者知道后者,反之则不然。领域层应该与外界完全隔离。

  • Communications inside your application — Hexagonal architecture prescribes a one-way flow of dependencies: from the application services layer to the domain layer. Classes inside the domain layer should only depend on each other; they should not depend on classes from the application services layer. This guideline flows from the previous one. The separation of concerns between the application services layer and the domain layer means that the former knows about the latter, but the opposite is not true. The domain layer should be fully isolated from the external world.

  • 应用程序之间的通信 ——外部应用程序通过应用程序服务层维护的公共接口连接到您的应用程序。没有人可以直接访问域层。六边形中的每一边代表一个进出应用程序的连接。

    请注意,虽然六边形有六个边,但这并不意味着您的应用程序只能连接到其他六个应用程序。连接数是任意的。关键是可以有很多这样的连接。

  • Communications between applications — External applications connect to your application through a common interface maintained by the application services layer. No one has a direct access to the domain layer. Each side in a hexagon represents a connection into or out of the application.

    Note that although a hexagon has six sides, it doesn’t mean your application can only connect to six other applications. The number of connections is arbitrary. The point is that there can be many such connections.

应用程序的每一层都表现出可观察的行为,并包含自己的一组实现细节。例如,领域层的可观察行为是该层的操作和状态的总和,这些操作和状态有助于应用程序服务层至少实现其目标之一。设计良好的 API 的原则具有分形性:它们同样适用于整个层或单个类。

Each layer of your application exhibits observable behavior and contains its own set of implementation details. For example, observable behavior of the domain layer is the sum of this layer’s operations and state that helps the application service layer achieve at least one of its goals. The principles of a well-designed API have a fractal nature: they apply equally to as much as a whole layer or as little as a single class.

当你对每一层的 API 进行了良好的设计(即隐藏其实现细节)时,你的测试也开始具有分形结构;他们验证有助于实现相同目标但处于不同级别的行为。涵盖应用程序服务的测试会检查该服务如何实现外部客户端提出的总体、粗粒度目标。同时,与领域类一起工作的测试验证了作为更大目标一部分的子目标(图5.10)。

When you make each layer’s API well-designed (that is, hide its implementation details), your tests also start to have a fractal structure; they verify behavior that helps achieve the same goals but at different levels. A test covering an application service checks to see how this service attains an overarching, coarse-grained goal posed by the external client. At the same time, a test working with a domain class verifies a subgoal that is part of that greater goal (figure 5.10).

图 5.10。使用不同层的测试具有分形性质:它们在不同层验证相同的行为。应用程序服务测试检查整个业务用例是如何执行的。使用域类的测试验证用例完成过程中的中间子目标。

Figure 5.10. Tests working with different layers have a fractal nature: they verify the same behavior at different levels. A test of an application service checks to see how the overall business use case is executed. A test working with a domain class verifies an intermediate subgoal on the way to use-case completion.

CH05 图10 分形

您可能还记得在前面的章节中我是如何提到您应该能够将任何测试追溯到特定的业务需求。每个测试都应该讲述一个对领域专家有意义的故事,如果没有,则强烈表明测试耦合到实现细节,因此很脆弱。我希望现在你能明白为什么。

You might remember from previous chapters how I mentioned that you should be able to trace any test back to a particular business requirement. Each test should tell a story that is meaningful to a domain expert, and if it doesn’t, that’s a strong indication that the test couples to implementation details and therefore is brittle. I hope now you can see why.

可观察到的行为从外层向内流动到中心。外部客户提出的总体目标被转化为由各个领域类实现的子目标。因此,域层中的每个可观察行为都保留了与特定业务用例的联系。您可以从最内层(域)层向外递归地跟踪此连接到应用程序服务层,然后再到外部客户端的需求。这种可追溯性遵循可观察行为的定义。要使一段代码成为可观察行为的一部分,它需要帮助客户实现其目标之一。对于领域类,客户端是一个应用服务;对于应用程序服务,它是外部客户端本身。

Observable behavior flows inward from outer layers to the center. The overarching goal posed by the external client gets translated into subgoals achieved by individual domain classes. Each piece of observable behavior in the domain layer therefore preserves the connection to a particular business use case. You can trace this connection recursively from the innermost (domain) layer outward to the application services layer and then to the needs of the external client. This traceability follows from the definition of observable behavior. For a piece of code to be part of observable behavior, it needs to help the client achieve one of its goals. For a domain class, the client is an application service; for the application service, it’s the external client itself.

使用设计良好的 API 验证代码库的测试也与业务需求有关,因为这些测试仅与可观察到的行为相关。一个很好的例子是清单5.6User中的和类(为方便起见,我在这里重复代码)。UserController

Tests that verify a code base with a well-designed API also have a connection to business requirements because those tests tie to the observable behavior only. A good example is the User and UserController classes from listing 5.6 (I’m repeating the code here for convenience).

清单 5.8。具有应用程序服务的域类

Listing 5.8. A domain class with an application service

公共类用户
{
    私有字符串_name;
    公共字符串名称
    {
        得到 => _名称;
        设置 => _name = NormalizeName(value);
    }

    私有字符串 NormalizeName(字符串名称)
    {
        /* 将名称缩减为 50 个字符 */
    }
}

公共类用户控制器
{
    public void RenameUser(int userId, string newName)
    {
        用户 user = GetUserFromDatabase(userId);
        user.Name = newName;
        保存用户到数据库(用户);
    }
}
public class User
{
    private string _name;
    public string Name
    {
        get => _name;
        set => _name = NormalizeName(value);
    }

    private string NormalizeName(string name)
    {
        /* Trim name down to 50 characters */
    }
}

public class UserController
{
    public void RenameUser(int userId, string newName)
    {
        User user = GetUserFromDatabase(userId);
        user.Name = newName;
        SaveUserToDatabase(user);
    }
}

UserController在这个例子中是一个应用程序服务。假设外部客户端并没有规范化用户名的特定目标,并且完全由于应用程序本身的限制而对所有名称进行规范化,则类NormalizeName中的方法User无法追溯到客户端的需求。因此,它是一个实现细节,应该私有化(我们在本章前面已经这样做了)。此外,测试不应直接检查此方法。他们应该只将它作为类的可观察行为的一部分进行验证——Name在这个例子中是属性的设置器。

UserController in this example is an application service. Assuming that the external client doesn’t have a specific goal of normalizing user names, and all names are normalized solely due to restrictions from the application itself, the NormalizeName method in the User class can’t be traced to the client’s needs. Therefore, it’s an implementation detail and should be made private (we already did that earlier in this chapter). Moreover, tests shouldn’t check this method directly. They should verify it only as part of the class’s observable behavior — the Name property’s setter in this example.

这种始终根据业务需求跟踪代码库的公共 API 的准则适用于绝大多数领域类和应用程序服务,但不太适用于实用程序和基础设施代码。此类代码解决的个别问题通常过于低级和细粒度,无法追溯到特定的业务用例。

This guideline of always tracing the code base’s public API to business requirements applies to the vast majority of domain classes and application services but less so to utility and infrastructure code. The individual problems such code solves are often too low-level and fine-grained and can’t be traced to a specific business use case.

5.3.2 系统内与系统间通信

5.3.2  Intra-system vs. inter-system communications

典型应用中有两种类型的通信:系统内和系统间。系统内通信是应用程序内部类之间的通信。系统间通信是指您的应用程序与其他应用程序对话(图5.11)。

There are two types of communications in a typical application: intra-system and inter-system. Intra-system communications are communications between classes inside your application. Inter-system communications are when your application talks to other applications (figure 5.11).

图 5.11。有两种类型的通信:系统内(应用程序内部的类之间)和系统间(应用程序之间)。

Figure 5.11. There are two types of communications: intra-system (between classes inside the application) and inter-system (between applications).

CH05 图11 内部外部

V03系统内通信是实现细节;系统间通信不是。

V03Intra-system communications are implementation details; inter-system communications are not.

系统内通信是实现细节,因为您的域类为执行操作而进行的协作不是它们可观察行为的一部分。这些合作与客户的目标没有直接联系。因此,耦合到此类协作会导致脆弱的测试。

Intra-system communications are implementation details because the collaborations your domain classes go through in order to perform an operation are not part of their observable behavior. These collaborations don’t have an immediate connection to the client’s goal. Thus, coupling to such collaborations leads to fragile tests.

系统间通信是另一回事。与应用程序内部类之间的协作不同,系统与外部世界对话的方式形成了整个系统的可观察行为。它是您的应用程序必须始终持有的合同的一部分(图5.12)。

Inter-system communications are a different matter. Unlike collaborations between classes inside your application, the way your system talks to the external world forms the observable behavior of that system as a whole. It’s part of the contract your application must hold at all times (figure 5.12).

图 5.12。系统间通信构成了整个应用程序的可观察行为。系统内通信是实现细节。

Figure 5.12. Inter-system communications form the observable behavior of your application as a whole. Intra-system communications are implementation details.

CH05 图12 内部外部2

系统间通信的这一属性源于单独的应用程序共同发展的方式。这种演变的主要原则之一是保持向后兼容性。无论您在系统内部执行何种重构,它用于与外部应用程序对话的通信模式都应始终保持不变,以便外部应用程序能够理解它。例如,您的应用程序在总线上发出的消息应保留其结构,向 SMTP 服务发出的调用应具有相同数量和类型的参数,等等。

This attribute of inter-system communications stems from the way separate applications evolve together. One of the main principles of such an evolution is maintaining backward compatibility. Regardless of the refactorings you perform inside your system, the communication pattern it uses to talk to external applications should always stay in place, so that external applications can understand it. For example, messages your application emits on a bus should preserve their structure, the calls issued to an SMTP service should have the same number and type of parameters, and so on.

在验证系统和外部应用程序之间的通信模式时,使用模拟是有益的。相反,使用模拟来验证系统内部类之间的通信会导致测试与实现细节相结合,因此不符合重构阻力指标。

The use of mocks is beneficial when verifying the communication pattern between your system and external applications. Conversely, using mocks to verify communications between classes inside your system results in tests that couple to implementation details and therefore fall short of the resistance-to-refactoring metric.

5.3.3 系统内与系统间通信:一个例子

5.3.3  Intra-system vs. inter-system communications: An example

为了说明系统内和系统间通信之间的区别,我将使用我在第 2 章和本章前面使用的Customer和类来扩展示例。Store想象一下以下业务用例:

To illustrate the difference between intra-system and inter-system communications, I’ll expand on the example with the Customer and Store classes that I used in chapter 2 and earlier in this chapter. Imagine the following business use case:

  • 顾客试图从商店购买产品。

  • A customer tries to purchase a product from a store.

  • 如果商店中的产品数量足够,则

    • 库存已从商店中移除。

    • 电子邮件收据将发送给客户。

    • 返回确认。

  • If the amount of the product in the store is sufficient, then

    • The inventory is removed from the store.

    • An email receipt is sent to the customer.

    • A confirmation is returned.

我们还假设该应用程序是一个没有用户界面的 API。

Let’s also assume that the application is an API with no user interface.

在下面的清单中,该类是一个应用程序服务,它协调域类( 、、)和外部应用程序(,它是 SMTP 服务的代理)CustomerController之间的工作。CustomerProductStoreEmailGateway

In the following listing, the CustomerController class is an application service that orchestrates the work between domain classes (Customer, Product, Store) and the external application (EmailGateway, which is a proxy to an SMTP service).

清单 5.9。将域模型与外部应用程序连接

Listing 5.9. Connecting the domain model with external applications

公共类 CustomerController
{
    public bool Purchase(int customerId, int productId, int quantity)
    {
        客户 customer = _customerRepository.GetById(customerId);
        产品 product = _productRepository.GetById(productId);

        bool isSuccess = customer.Purchase(
            _mainStore、产品、数量);

        如果(成功)
        {
            _emailGateway.SendReceipt(
                customer.Email, product.Name, quantity);
        }

        返回成功;
    }
}
public class CustomerController
{
    public bool Purchase(int customerId, int productId, int quantity)
    {
        Customer customer = _customerRepository.GetById(customerId);
        Product product = _productRepository.GetById(productId);

        bool isSuccess = customer.Purchase(
            _mainStore, product, quantity);

        if (isSuccess)
        {
            _emailGateway.SendReceipt(
                customer.Email, product.Name, quantity);
        }

        return isSuccess;
    }
}

为简洁起见,省略了输入参数的验证。在该Purchase方法中,客户检查商店中是否有足够的库存,如果足够,则减少产品数量。

Validation of input parameters is omitted for brevity. In the Purchase method, the customer checks to see if there’s enough inventory in the store and, if so, decreases the product amount.

进行购买的行为是具有系统内和系统间通信的业务用例。系统间通信是CustomerController应用程序服务与两个外部系统之间的通信:第三方应用程序(也是启动用例的客户端)和电子邮件网关。系统内通信发生在域类CustomerStore域类之间(图5.13)。

The act of making a purchase is a business use case with both intra-system and inter-system communications. The inter-system communications are those between the CustomerController application service and the two external systems: the third-party application (which is also the client initiating the use case) and the email gateway. The intra-system communication is between the Customer and the Store domain classes (figure 5.13).

图 5.13。清单5.9中的示例使用六边形架构表示。六边形之间的通信是系统间通信。六边形内部的通信是系统内的。

Figure 5.13. The example in listing 5.9 represented using the hexagonal architecture. The communications between the hexagons are inter-system communications. The communication inside the hexagon is intra-system.

CH05 图13 例1

在此示例中,对 SMTP 服务的调用是对外部世界可见的副作用,因此形成了整个应用程序的可观察行为。它还与客户的目标直接相关。应用的客户端是第三方系统。该系统的目标是进行购买,它希望客户收到确认电子邮件作为成功结果的一部分。

In this example, the call to the SMTP service is a side effect that is visible to the external world and thus forms the observable behavior of the application as a whole. It also has a direct connection to the client’s goals. The client of the application is the third-party system. This system’s goal is to make a purchase, and it expects the customer to receive a confirmation email as part of the successful outcome.

调用 SMTP 服务是进行模拟的正当理由。它不会导致测试脆弱性,因为您希望确保这种类型的通信即使在重构之后也能保持原样。使用模拟可以帮助您做到这一点。

The call to the SMTP service is a legitimate reason to do mocking. It doesn’t lead to test fragility because you want to make sure this type of communication stays in place even after refactoring. The use of mocks helps you do exactly that.

下一个清单显示了合法使用模拟的示例。

The next listing shows an example of a legitimate use of mocks.

清单 5.10。不会导致脆弱测试的模拟

Listing 5.10. Mocking that doesn’t lead to fragile tests

[事实]
公共无效成功_购买()
{
    var mock = new Mock<IEmailGateway>();
    var sut = new CustomerController(mock.Object);

    bool isSuccess = sut.Purchase(
        customerId: 1, productId: 2, 数量: 5);

    断言.True(isSuccess);
    模拟。验证(                                    
        x => x.SendReceipt(                         
            "customer@email.com", "洗发水", 5),   
        次.一次);                                
}
[Fact]
public void Successful_purchase()
{
    var mock = new Mock<IEmailGateway>();
    var sut = new CustomerController(mock.Object);

    bool isSuccess = sut.Purchase(
        customerId: 1, productId: 2, quantity: 5);

    Assert.True(isSuccess);
    mock.Verify(                                   
        x => x.SendReceipt(                        
            "customer@email.com", "Shampoo", 5),   
        Times.Once);                               
}

验证系统是否发送了有关购买的收据

Verifies that the system sent a receipt about the purchase

请注意,该isSuccess标志也可以被外部客户端观察到,也需要验证。不过,这个标志不需要模拟;一个简单的值比较就足够了。

Note that the isSuccess flag is also observable by the external client and also needs verification. This flag doesn’t need mocking, though; a simple value comparison is enough.

Customer现在让我们看一个模拟和之间通信的测试Store

Let’s now look at a test that mocks the communication between Customer and Store.

清单 5.11。模拟导致脆弱的测试

Listing 5.11. Mocking that leads to fragile tests

[事实]
public void Purchase_succeeds_when_enough_inventory()
{
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .返回(真);
    var customer = new Customer();

    布尔成功 = customer.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    断言。真(成功);
    storeMock.验证(
        x => x.RemoveInventory(Product.Shampoo, 5),
        次.Once);
}
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
    var storeMock = new Mock<IStore>();
    storeMock
        .Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
        .Returns(true);
    var customer = new Customer();

    bool success = customer.Purchase(
        storeMock.Object, Product.Shampoo, 5);

    Assert.True(success);
    storeMock.Verify(
        x => x.RemoveInventory(Product.Shampoo, 5),
        Times.Once);
}

CustomerController与 SMTP 服务之间的通信不同,从到 的RemoveInventory()方法调用不会跨越应用程序边界:调用者和接收者都驻留在应用程序内。此外,此方法既不是帮助客户端实现其目标的操作,也不是状态。这两个领域类的客户的目标是进行购买。与此目标有直接联系的仅有的两个成员是和。该方法发起购买,并显示购买完成后的系统状态。方法调用是实现客户目标的中间步骤:实现细节。CustomerStoreCustomerControllercustomer.Purchase()store.GetInventory()Purchase()GetInventory()RemoveInventory()

Unlike the communication between CustomerController and the SMTP service, the RemoveInventory() method call from Customer to Store doesn’t cross the application boundary: both the caller and the recipient reside inside the application. Also, this method is neither an operation nor a state that helps the client achieve its goals. The client of these two domain classes is CustomerController with the goal of making a purchase. The only two members that have an immediate connection to this goal are customer.Purchase() and store.GetInventory(). The Purchase() method initiates the purchase, and GetInventory() shows the state of the system after the purchase is completed. The RemoveInventory() method call is an intermediate step on the way to the client’s goal: an implementation detail.

5.4 经典单元测试与伦敦单元测试学派的再访

5.4  The classical vs. London schools of unit testing, revisited

我重复第 2 章中的下表。提醒一下,该表总结了经典单元测试和伦敦单元测试学派之间的差异。

I’m repeating the following table from chapter 2. As a reminder, this table sums up the differences between the classical and London schools of unit testing.

表 5.2。单元测试的伦敦学派和经典学派之间的区别

Table 5.2. The differences between the London and classical schools of unit testing

  隔离… 一个单位是… 将测试替身用于……

伦敦学校

London school

单位

Units

一类

A class

除了不可变的依赖项

All but immutable dependencies

古典派

Classical school

单元测试

Unit tests

一个类或一组类

A class or a set of classes

共享依赖

Shared dependencies

在第 2 章中,我提到我更喜欢经典的单元测试学派而不是伦敦学派。我希望现在你能明白为什么。伦敦学派鼓励对除不可变依赖之外的所有应用使用 mock,并且不区分系统内和系统间通信。因此,测试检查类之间的通信与检查应用程序和外部系统之间的通信一样多。

In chapter 2, I mentioned that I prefer the classical school of unit testing over the London school. I hope now you can see why. The London school encourages the use of mocks for all but immutable dependencies and doesn’t differentiate between intra-system and inter-system communications. As a result, tests check communications between classes just as much as they check communications between your application and external systems.

这种不加区别地使用 mock 的做法是为什么效仿伦敦学派的结果往往导致测试与实现细节相结合,从而缺乏对重构的抵抗力。您可能还记得第 4 章,重构阻力的度量(与其他三个不同)主要是二元选择:测试要么具有重构阻力,要么没有。在此指标上妥协会使测试几乎毫无价值。

This indiscriminate use of mocks is why following the London school often results in tests that couple to implementation details and thus lack resistance to refactoring. As you may remember from chapter 4, the metric of resistance to refactoring (unlike the other three) is mostly a binary choice: a test either has resistance to refactoring or it doesn’t. Compromising on this metric renders the test nearly worthless.

经典学派在这个问题上要好得多,因为它提倡只替换测试之间共享的依赖关系,这几乎总是转化为进程外依赖关系,例如 SMTP 服务、消息总线等。但古典学派对系统间通信的处理也不尽如人意。这所学校还鼓励过度使用模拟,尽管不如伦敦学校那么多。

The classical school is much better at this issue because it advocates for substituting only dependencies that are shared between tests, which almost always translates into out-of-process dependencies such as an SMTP service, a message bus, and so on. But the classical school is not ideal in its treatment of inter-system communications, either. This school also encourages excessive use of mocks, albeit not as much as the London school.

5.4.1 并非所有的进程外依赖都应该被模拟掉

5.4.1  Not all out-of-process dependencies should be mocked out

让我快速回顾一下依赖类型(更多详细信息,请参阅第 2 章):

Let me give you a quick refresher on types of dependencies (refer to chapter 2 for more details):

  • 共享依赖 ——由测试共享的依赖(不是生产代码)

  • Shared dependency — A dependency shared by tests (not production code)

  • 进程外依赖性 ——由程序执行进程以外的进程(例如,数据库、消息总线或 SMTP 服务)承载的依赖性

  • Out-of-process dependency — A dependency hosted by a process other than the program’s execution process (for example, a database, a message bus, or an SMTP service)

  • 私有依赖 ——任何不共享的依赖

  • Private dependency — Any dependency that is not shared

经典学派建议避免共享依赖关系,因为它们为测试提供了干扰彼此执行上下文的方法,从而防止这些测试并行运行。测试并行、顺序和以任何顺序运行的能力称为测试隔离

The classical school recommends avoiding shared dependencies because they provide the means for tests to interfere with each other’s execution context and thus prevent those tests from running in parallel. The ability for tests to run in parallel, sequentially, and in any order is called test isolation.

如果共享依赖项不是进程外的,那么通过在每次测试运行时提供它的新实例,很容易避免在测试中重复使用它。在共享依赖项在进程外的情况下,测试变得更加复杂。您不能在每次测试执行之前实例化一个新的数据库或提供一个新的消息总线;那会大大减慢测试套件的速度。通常的方法是用测试替身(模拟和存根)替换此类依赖项。

If a shared dependency is not out-of-process, then it’s easy to avoid reusing it in tests by providing a new instance of it on each test run. In cases where the shared dependency is out-of-process, testing becomes more complicated. You can’t instantiate a new database or provision a new message bus before each test execution; that would drastically slow down the test suite. The usual approach is to replace such dependencies with test doubles — mocks and stubs.

不过,并非所有进程外依赖项都应该被模拟掉。如果只能通过您的应用程序访问进程外依赖项,那么与此类依赖项的通信不是系统可观察行为的一部分。无法从外部观察到的进程外依赖性实际上充当应用程序的一部分(图5.14)。

Not all out-of-process dependencies should be mocked out, though. If an out-of-process dependency is only accessible through your application, then communications with such a dependency are not part of your system’s observable behavior. An out-of-process dependency that can’t be observed externally, in effect, acts as part of your application (figure 5.14).

图 5.14。与外部无法观察到的进程外依赖项的通信是实现细节。它们不必在重构后留在原地,因此不应该用模拟来验证。

Figure 5.14. Communications with an out-of-process dependency that can’t be observed externally are implementation details. They don’t have to stay in place after refactoring and therefore shouldn’t be verified with mocks.

CH05 图14

请记住,始终保持应用程序和外部系统之间的通信模式的要求源于维护向后兼容性的必要性。您必须保持应用程序与外部系统对话的方式。那是因为您不能在更改应用程序的同时更改这些外部系统;它们可能遵循不同的部署周期,或者您可能根本无法控制它们。

Remember, the requirement to always preserve the communication pattern between your application and external systems stems from the necessity to maintain backward compatibility. You have to maintain the way your application talks to external systems. That’s because you can’t change those external systems simultaneously with your application; they may follow a different deployment cycle, or you might simply not have control over them.

但是当您的应用程序充当外部系统的代理,并且没有客户端可以直接访问它时,向后兼容性要求就消失了。现在您可以将您的应用程序与这个外部系统一起部署,并且不会影响客户端。与此类系统的通信模式成为实现细节。

But when your application acts as a proxy to an external system, and no client can access it directly, the backward-compatibility requirement vanishes. Now you can deploy your application together with this external system, and it won’t affect the clients. The communication pattern with such a system becomes an implementation detail.

这里的一个很好的例子是应用程序数据库:一个仅供您的应用程序使用的数据库。没有外部系统可以访问该数据库。因此,您可以按照您喜欢的任何方式修改您的系统和应用程序数据库之间的通信模式,只要它不破坏现有功能即可。因为该数据库完全不为客户所见,您甚至可以用完全不同的存储机制替换它,而且没有人会注意到。

A good example here is an application database: a database that is used only by your application. No external system has access to this database. Therefore, you can modify the communication pattern between your system and the application database in any way you like, as long as it doesn’t break existing functionality. Because that database is completely hidden from the eyes of the clients, you can even replace it with an entirely different storage mechanism, and no one will notice.

对您可以完全控制的进程外依赖项使用模拟也会导致脆弱的测试。您不希望每次在数据库中拆分表或修改存储过程中的一个参数的类型时,测试都变成红色。数据库和您的应用程序必须被视为一个系统。

The use of mocks for out-of-process dependencies that you have a full control over also leads to brittle tests. You don’t want your tests to turn red every time you split a table in the database or modify the type of one of the parameters in a stored procedure. The database and your application must be treated as one system.

这显然提出了一个问题。在不影响反馈速度(良好单元测试的第三个属性)的情况下,您将如何测试具有这种依赖性的工作?您将在接下来的两章中深入了解这个主题。

This obviously poses an issue. How would you test the work with such a dependency without compromising the feedback speed, the third attribute of a good unit test? You’ll see this subject covered in depth in the following two chapters.

5.4.2 使用模拟来验证行为

5.4.2  Using mocks to verify behavior

人们常说模拟是为了验证行为。在绝大多数情况下,他们没有。每个单独的类与相邻类交互以实现某个目标的方式与可观察到的行为无关;这是一个实现细节。

Mocks are often said to verify behavior. In the vast majority of cases, they don’t. The way each individual class interacts with neighboring classes in order to achieve some goal has nothing to do with observable behavior; it’s an implementation detail.

验证类之间的通信类似于试图通过测量大脑中的神经元在彼此之间传递的信号来推导一个人的行为。这种详细程度过于细化。重要的是可以追溯到客户目标的行为。当客户请求您提供帮助时,他们并不关心您大脑中的哪些神经元会亮起。唯一重要的是帮助本身——当然是由您以可靠和专业的方式提供的。只有当模拟验证跨越应用程序边界的交互并且只有当这些交互的副作用对外部世界可见时,模拟才与行为有关。

Verifying communications between classes is akin to trying to derive a person’s behavior by measuring the signals that neurons in the brain pass among each other. Such a level of detail is too granular. What matters is the behavior that can be traced back to the client goals. The client doesn’t care what neurons in your brain light up when they ask you to help. The only thing that matters is the help itself — provided by you in a reliable and professional fashion, of course. Mocks have something to do with behavior only when they verify interactions that cross the application boundary and only when the side effects of those interactions are visible to the external world.

5.5 总结

5.5  Summary

  • 测试替身是一个包罗万象的术语,它描述了测试中各种非生产就绪的虚假依赖项。测试替身有五种变体——dummy、stub、spy、mock 和 fake——它们可以分为两种类型:mocks 和 stubs。间谍在功能上与模拟相同;假人和假人的作用与存根相同。

  • Test double is an overarching term that describes all kinds of non-production-ready, fake dependencies in tests. There are five variations of test doubles — dummy, stub, spy, mock, and fake — that can be grouped in just two types: mocks and stubs. Spies are functionally the same as mocks; dummies and fakes serve the same role as stubs.

  • Mocks 帮助模拟和检查结果交互:从 SUT 调用它的依赖项,改变这些依赖项的状态。存根有助于模拟传入的交互:调用 SUT 对其依赖项进行调用以获取输入数据。

  • Mocks help emulate and examine outcoming interactions: calls from the SUT to its dependencies that change the state of those dependencies. Stubs help emulate incoming interactions: calls the SUT makes to its dependencies to get input data.

  • 模拟(工具)是模拟库中的一个类,您可以使用它来创建模拟(测试替身)或存根。

  • A mock (the tool) is a class from a mocking library that you can use to create a mock (the test double) or a stub.

  • 断言与存根的交互会导致脆弱的测试。这样的交互并不对应最终结果;这是实现该结果的中间步骤,一个实现细节。

  • Asserting interactions with stubs leads to fragile tests. Such an interaction doesn’t correspond to the end result; it’s an intermediate step on the way to that result, an implementation detail.

  • 命令查询分离 (CQS) 原则指出每个方法都应该是命令或查询,但不能同时是两者。替代命令的测试替身是模拟的。替代查询的测试替身是存根。

  • The command query separation (CQS) principle states that every method should be either a command or a query but not both. Test doubles that substitute commands are mocks. Test doubles that substitute queries are stubs.

  • 所有生产代码都可以按两个维度分类:公共 API 与私有 API,以及可观察的行为与实现细节。代码公开性由访问修饰符控制,例如privatepublicinternal关键字。当代码满足以下要求之一时,它就是可观察行为的一部分(任何其他代码都是实现细节):

    • 它公开了帮助客户端实现其目标之一的操作。操作是一种执行计算或产生副作用的方法

    • 它公开了一种状态,可以帮助客户端实现其目标之一。状态是系统的当前状态。

  • All production code can be categorized along two dimensions: public API versus private API, and observable behavior versus implementation details. Code publicity is controlled by access modifiers, such as private, public, and internal keywords. Code is part of observable behavior when it meets one of the following requirements (any other code is an implementation detail):

    • It exposes an operation that helps the client achieve one of its goals. An operation is a method that performs a calculation or incurs a side effect.

    • It exposes a state that helps the client achieve one of its goals. State is the current condition of the system.

  • 设计良好的代码是其可观察行为与公共 API 一致并且其实现细节隐藏在私有 API 之后的代码。当代码的公共 API 超出可观察的行为范围时,代码会泄露实现细节。

  • Well-designed code is code whose observable behavior coincides with the public API and whose implementation details are hidden behind the private API. A code leaks implementation details when its public API extends beyond the observable behavior.

  • 封装是保护您的代码免受不变违规的行为。公开实现细节通常会破坏封装,因为客户可以使用实现细节来绕过代码的不变量。

  • Encapsulation is the act of protecting your code against invariant violations. Exposing implementation details often entails a breach in encapsulation because clients can use implementation details to bypass the code’s invariants.

  • 六边形架构是一组以六边形表示的交互应用程序。每个六边形由两层组成:域和应用程序服务。

  • Hexagonal architecture is a set of interacting applications represented as hexagons. Each hexagon consists of two layers: domain and application services.

  • 六边形架构强调三个重要方面:

    • 域和应用程序服务层之间的关注点分离。领域层应该负责业务逻辑,而应用服务应该协调领域层和外部应用之间的工作。

    • 从应用服务层到领域层的一种单向依赖流。领域层内的类应该只相互依赖;它们不应该依赖于应用服务层的类。

    • 外部应用程序通过应用程序服务层维护的公共接口连接到您的应用程序。没有人可以直接访问域层。

  • Hexagonal architecture emphasizes three important aspects:

    • Separation of concerns between the domain and application services layers. The domain layer should be responsible for the business logic, while the application services should orchestrate the work between the domain layer and external applications.

    • A one-way flow of dependencies from the application services layer to the domain layer. Classes inside the domain layer should only depend on each other; they should not depend on classes from the application services layer.

    • External applications connect to your application through a common interface maintained by the application services layer. No one has a direct access to the domain layer.

  • 六边形中的每一层都表现出可观察的行为,并包含自己的一组实现细节。

  • Each layer in a hexagon exhibits observable behavior and contains its own set of implementation details.

  • 应用程序中有两种类型的通信:系统内和系统间。系统内通信是应用程序内部类之间的通信。系统间通信是应用程序与外部应用程序对话的时候。

  • There are two types of communications in an application: intra-system and inter-system. Intra-system communications are communications between classes inside the application. Inter-system communication is when the application talks to external applications.

  • 系统内通信是实现细节。系统间通信是可观察行为的一部分,但只能通过您的应用程序访问的外部系统除外。与此类系统的交互也是实现细节,因为外部无法观察到由此产生的副作用。

  • Intra-system communications are implementation details. Inter-system communications are part of observable behavior, with the exception of external systems that are accessible only through your application. Interactions with such systems are implementation details too, because the resulting side effects are not observed externally.

  • 使用模拟断言系统内通信会导致脆弱的测试。仅当模拟用于系统间通信(跨越应用程序边界的通信)并且仅当外部世界可以看到这些通信的副作用时,模拟才是合法的。

  • Using mocks to assert intra-system communications leads to fragile tests. Mocking is legitimate only when it’s used for inter-system communications — communications that cross the application boundary — and only when the side effects of those communications are visible to the external world.

6

6

单元测试的风格

Styles of unit testing

本章涵盖:

This chapter covers:

  • 比较单元测试的风格

  • Comparing styles of unit testing

  • 功能和六边形架构之间的关系

  • The relationship between functional and hexagonal architectures

  • 过渡到基于输出的测试

  • Transitioning to output-based testing

第 4 章介绍了一个好的单元测试的四个属性:防止回归、抗重构、快速反馈和可维护性。这些属性构成了一个参考框架,您可以使用它来分析特定测试和单元测试方法。我们在第 5 章中分析了一种这样的方法:模拟的使用。

Chapter 4 introduced the four attributes of a good unit test: protection against regressions, resistance to refactoring, fast feedback, and maintainability. These attributes form a frame of reference that you can use to analyze specific tests and unit testing approaches. We analyzed one such approach in chapter 5: the use of mocks.

在本章中,我将相同的参考框架应用于单元测试风格的主题。共有三种这样的风格:基于输出的、基于状态的和基于通信的测试。在这三者中,基于输出的风格产生最高质量的测试,基于状态的测试是第二好的选择,而基于通信的测试应该只是偶尔使用。

In this chapter, I apply the same frame of reference to the topic of unit testing styles. There are three such styles: output-based, state-based, and communication-based testing. Among the three, the output-based style produces tests of the highest quality, state-based testing is the second-best choice, and communication-based testing should be used only occasionally.

不幸的是,您不能在任何地方都使用基于输出的测试风格。它仅适用于以纯函数方式编写的代码。但别担心;有一些技术可以帮助您将更多测试转换为基于输出的样式。为此,您需要使用函数式编程原则将底层代码重构为函数式架构。

Unfortunately, you can’t use the output-based testing style everywhere. It’s only applicable to code written in a purely functional way. But don’t worry; there are techniques that can help you transform more of your tests into the output-based style. For that, you’ll need to use functional programming principles to restructure the underlying code toward a functional architecture.

请注意,本章并未深入探讨函数式编程的主题。尽管如此,到本章结束时,我希望您对函数式编程与基于输出的测试的关系有一个直观的理解。您还将了解如何使用基于输出的样式编写更多测试,以及函数式编程和函数式架构的局限性。

Note that this chapter doesn’t provide a deep dive into the topic of functional programming. Still, by the end of this chapter, I hope you’ll have an intuitive understanding of how functional programming relates to output-based testing. You’ll also learn how to write more of your tests using the output-based style, as well as the limitations of functional programming and functional architecture.

6.1 单元测试的三种风格

6.1  The three styles of unit testing

正如我在本章介绍中提到的,单元测试分为三种类型:

As I mentioned in the chapter introduction, there are three styles of unit testing:

  • 基于输出的测试

  • Output-based testing

  • 基于状态的测试

  • State-based testing

  • 基于通信的测试

  • Communication-based testing

本节通过定义(通过示例)所有三种单元测试样式为整章奠定基础。在那之后的部分中,您将看到他们如何相互得分。

This section lays the foundation for the whole chapter by defining (with examples) all three styles of unit testing. You’ll see how they score against each other in the section after that.

6.1.1 定义基于输出的样式

6.1.1  Defining the output-based style

第一种单元测试风格是基于输出的风格,您将输入提供给被测系统 (SUT) 并检查它产生的输出(图6.1)。这种类型的单元测试只适用于不改变全局或内部状态的代码,因此唯一要验证的组件是它的返回值。

The first style of unit testing is the output-based style, where you feed an input to the system under test (SUT) and check the output it produces (figure 6.1). This style of unit testing is only applicable to code that doesn’t change a global or internal state, so the only component to verify is its return value.

图 6.1。在基于输出的测试中,测试验证系统生成的输出。这种测试方式假定没有副作用,并且 SUT 工作的唯一结果是它返回给调用者的值。

Figure 6.1. In output-based testing, tests verify the output the system generates. This style of testing assumes there are no side effects and the only result of the SUT’s work is the value it returns to the caller.

CH06 图 1 输出基础 2

以下清单显示了此类代码的示例和覆盖它的测试。该类PriceEngine接受一系列产品并计算折扣。

The following listing shows an example of such code and a test covering it. The PriceEngine class accepts an array of products and calculates a discount.

清单 6.1。基于输出的测试

Listing 6.1. Output-based testing

公开课 PriceEngine
{
    公共小数 CalculateDiscount(参数产品 [] 产品)
    {
        小数折扣 = products.Length * 0.01m;
        返回 Math.Min(折扣, 0.2m);
    }
}

[事实]
公共无效 Discount_of_two_products()
{
    var product1 = new Product("洗手液");
    var product2 = new Product("洗发水");
    var sut = new PriceEngine();

    小数折扣 = sut.CalculateDiscount(product1, product2);

    Assert.Equal(0.02m, 折扣);
}
public class PriceEngine
{
    public decimal CalculateDiscount(params Product[] products)
    {
        decimal discount = products.Length * 0.01m;
        return Math.Min(discount, 0.2m);
    }
}

[Fact]
public void Discount_of_two_products()
{
    var product1 = new Product("Hand wash");
    var product2 = new Product("Shampoo");
    var sut = new PriceEngine();

    decimal discount = sut.CalculateDiscount(product1, product2);

    Assert.Equal(0.02m, discount);
}

PriceEngine将产品数量乘以 1%,并将结果上限设为 20%。这堂课没有别的了。它不会将产品添加到任何内部集合,也不会将它们保存在数据库中。该方法的唯一结果CalculateDiscount()是它返回的折扣:输出值(图6.2)。

PriceEngine multiplies the number of products by 1% and caps the result at 20%. There’s nothing else to this class. It doesn’t add the products to any internal collection, nor does it persist them in a database. The only outcome of the CalculateDiscount() method is the discount it returns: the output value (figure 6.2).

图 6.2。 PriceEngine使用输入输出符号表示。它的CalculateDiscount()方法接受一系列产品并计算折扣。

Figure 6.2. PriceEngine represented using input-output notation. Its CalculateDiscount() method accepts an array of products and calculates a discount.

CH06图2输出基础3

基于输出的单元测试风格也称为功能测试。这个名字源于函数式编程,一种强调对无副作用代码的偏好的编程方法。我们将在本章后面详细讨论函数式编程和函数式架构。

The output-based style of unit testing is also known as functional. This name takes root in functional programming, a method of programming that emphasizes a preference for side-effect-free code. We’ll talk more about functional programming and functional architecture later in this chapter.

6.1.2 定义基于状态的风格

6.1.2  Defining the state-based style

基于状态的风格是关于在操作完成后验证系统的状态(图6.3)。这种测试风格中的术语状态可以指 SUT 本身的状态、其合作者之一的状态,或者进程外依赖项的状态,例如数据库或文件系统。

The state-based style is about verifying the state of the system after an operation is complete (figure 6.3). The term state in this style of testing can refer to the state of the SUT itself, of one of its collaborators, or of an out-of-process dependency, such as the database or the filesystem.

图 6.3。在基于状态的测试中,测试会在操作完成后验证系统的最终状态。虚线圆圈代表最终状态。

Figure 6.3. In state-based testing, tests verify the final state of the system after an operation is complete. The dashed circles represent that final state.

CH06 图3状态基础2

这是基于状态的测试的示例。该类Order允许客户添加新产品。

Here’s an example of state-based testing. The Order class allows the client to add a new product.

清单 6.2。基于状态的测试

Listing 6.2. State-based testing

公共课秩序
{
    private readonly List<Product> _products = new List<Product>();
    public IReadOnlyList<Product> Products => _products.ToList();

    public void AddProduct(产品产品)
    {
        _products.Add(产品);
    }
}

[事实]
public void Adding_a_product_to_an_order()
{
    var product = new Product("洗手液");
    var sut = new Order();

    sut.AddProduct(产品);

    Assert.Equal(1, sut.Products.Count);
    Assert.Equal(product, sut.Products[0]);
}
public class Order
{
    private readonly List<Product> _products = new List<Product>();
    public IReadOnlyList<Product> Products => _products.ToList();

    public void AddProduct(Product product)
    {
        _products.Add(product);
    }
}

[Fact]
public void Adding_a_product_to_an_order()
{
    var product = new Product("Hand wash");
    var sut = new Order();

    sut.AddProduct(product);

    Assert.Equal(1, sut.Products.Count);
    Assert.Equal(product, sut.Products[0]);
}

Products添加完成后测试验证集合。与清单6.1中基于输出的测试示例不同,结果AddProduct()是对订单状态所做的更改。

The test verifies the Products collection after the addition is completed. Unlike the example of output-based testing in listing 6.1, the outcome of AddProduct() is the change made to the order’s state.

6.1.3 定义基于通信的风格

6.1.3  Defining the communication-based style

最后,第三种单元测试方式是基于通信的测试。这种风格使用模拟来验证被测系统与其协作者之间的通信(图6.4)。

Finally, the third style of unit testing is communication-based testing. This style uses mocks to verify communications between the system under test and its collaborators (figure 6.4).

图 6.4。在基于通信的测试中,测试用模拟替换 SUT 的协作者并验证 SUT 是否正确调用这些协作者。

Figure 6.4. In communication-based testing, tests substitute the SUT’s collaborators with mocks and verify that the SUT calls those collaborators correctly.

CH06 图4 通讯基础2

以下清单显示了基于通信的测试示例。

The following listing shows an example of communication-based testing.

清单 6.3。基于通信的测试

Listing 6.3. Communication-based testing

[事实]
public void Sending_a_greetings_email()
{
    var emailGatewayMock = new Mock<IEmailGateway>();
    var sut = new Controller(emailGatewayMock.Object);

    sut.GreetUser("user@email.com");

    emailGatewayMock.验证(
        x => x.SendGreetingsEmail("user@email.com"),
        次.Once);
}
[Fact]
public void Sending_a_greetings_email()
{
    var emailGatewayMock = new Mock<IEmailGateway>();
    var sut = new Controller(emailGatewayMock.Object);

    sut.GreetUser("user@email.com");

    emailGatewayMock.Verify(
        x => x.SendGreetingsEmail("user@email.com"),
        Times.Once);
}

您可以在单个测试中同时使用一种、两种甚至所有三种类型的单元测试。

You can employ one, two, or even all three styles of unit testing together in a single test.

单元测试的风格和流派

Styles and schools of unit testing

经典的单元测试学派更喜欢基于状态的风格而不是基于通信的风格。伦敦学派则做出相反的选择。两所学校都使用基于输出的测试。

The classical school of unit testing prefers the state-based style over the communication-based one. The London school makes the opposite choice. Both schools use output-based testing.

6.2 比较三种单元测试风格

6.2  Comparing the three styles of unit testing

基于输出、基于状态和基于通信的单元测试风格并没有什么新鲜事。事实上,您之前已经在本书中看到了所有这些样式。有趣的是使用良好单元测试的四个属性将它们相互比较。这里又是那些属性(更多细节请参考第 4 章):

There’s nothing new about output-based, state-based, and communication-based styles of unit testing. In fact, you already saw all of these styles previously in this book. What’s interesting is comparing them to each other using the four attributes of a good unit test. Here are those attributes again (refer to chapter 4 for more details):

  • 防止回归

  • Protection against regressions

  • 抵制重构

  • Resistance to refactoring

  • 快速反馈

  • Fast feedback

  • 可维护性

  • Maintainability

在我们的比较中,让我们分别看看这四个。

In our comparison, let’s look at each of the four separately.

6.2.1 使用回归保护和反馈速度指标比较样式

6.2.1  Comparing the styles using the metrics of protection against regressions and feedback speed

让我们首先在防止回归和反馈速度属性方面比较这三种风格,因为这些属性在这个特定的比较中是最直接的。防止回归的指标不依赖于特定的测试方式。请注意,该指标是以下三个特征的产物:

Let’s first compare the three styles in terms of the protection against regressions and feedback speed attributes, as these attributes are the most straightforward in this particular comparison. The metric of protection against regressions doesn’t depend on a particular style of testing. Mind you, this metric is a product of the following three characteristics:

  • 测试期间执行的代码量

  • The amount of code that is executed during the test

  • 该代码的复杂性

  • The complexity of that code

  • 领域意义

  • Its domain significance

通常,您可以编写一个测试,根据您的喜好编写尽可能多或尽可能少的代码;没有特定的样式在这方面提供好处。代码的复杂性和领域重要性也是如此。唯一的例外是基于通信的风格:过度使用它会导致肤浅的测试,只验证一小部分代码并模拟其他所有内容。不过,这种肤浅并不是基于通信的测试的决定性特征,而是滥用这种技术的极端情况。

Generally, you can write a test that exercises as much or as little code as you like; no particular style provides a benefit in this area. The same is true for the code’s complexity and domain significance. The only exception is the communication-based style: overusing it can result in shallow tests that verify only a thin slice of code and mock out everything else. Such shallowness is not a definitive feature of communication-based testing, though, but rather is an extreme case of abusing this technique.

测试风格与测试的反馈速度之间几乎没有相关性。只要您的测试不触及进程外依赖性并因此停留在单元测试领域,所有样式都会产生大致相同执行速度的测试。基于通信的测试可能会稍差一些,因为模拟往往会在运行时引入额外的延迟。但是差别可以忽略不计,除非你有数以万计的这样的测试。

There’s little correlation between the styles of testing and the test’s feedback speed. As long as your tests don’t touch out-of-process dependencies and thus stay in the realm of unit testing, all styles produce tests of roughly equal speed of execution. Communication-based testing can be slightly worse because mocks tend to introduce additional latency at runtime. But the difference is negligible, unless you have tens of thousands of such tests.

6.2.2 使用重构阻力指标比较样式

6.2.2  Comparing the styles using the metric of resistance to refactoring

当谈到重构阻力的指标时,情况就不同了。重构阻力是衡量在重构过程中产生了多少误报(误报)测试。反过来,误报是测试耦合到代码的实现细节而不是可观察到的行为的结果。

When it comes to the metric of resistance to refactoring, the situation is different. Resistance to refactoring is the measure of how many false positives (false alarms) tests generate during refactorings. False positives, in turn, are a result of tests coupling to code’s implementation details as opposed to observable behavior.

基于输出的测试提供了最好的防止误报的保护,因为生成的测试仅与被测方法耦合。此类测试与实现细节耦合的唯一方法是被测方法本身就是一个实现细节。

Output-based testing provides the best protection against false positives because the resulting tests couple only to the method under test. The only way for such tests to couple to implementation details is when the method under test is itself an implementation detail.

基于状态的测试通常更容易出现误报。除了被测方法之外,此类测试还适用于类的状态。从概率上讲,测试和生产代码之间的耦合度越大,该测试与泄漏的实现细节相关联的可能性就越大。基于状态的测试与更大的 API 表面相关联,因此将它们耦合到实现细节的机会也更高。

State-based testing is usually more prone to false positives. In addition to the method under test, such tests also work with the class’s state. Probabilistically speaking, the greater the coupling between the test and the production code, the greater the chance for this test to tie to a leaking implementation detail. State-based tests tie to a larger API surface, and hence the chances of coupling them to implementation details are also higher.

基于通信的测试最容易出现误报。你可能还记得第 5 章,绝大多数检查与测试替身交互的测试最终都很脆弱。与存根的交互总是如此——你永远不应该检查这样的交互。只有当它们验证跨越应用程序边界的交互并且只有当这些交互的副作用对外部世界可见时,Mock 才有用。如您所见,使用基于通信的测试需要格外谨慎,以保持对重构的适当抵抗。

Communication-based testing is the most vulnerable to false alarms. As you may remember from chapter 5, the vast majority of tests that check interactions with test doubles end up being brittle. This is always the case for interactions with stubs — you should never check such interactions. Mocks are fine only when they verify interactions that cross the application boundary and only when the side effects of those interactions are visible to the external world. As you can see, using communication-based testing requires extra prudence in order to maintain proper resistance to refactoring.

但就像肤浅一样,脆弱也不是基于沟通的风格的决定性特征。您可以通过维护适当的封装并将测试仅耦合到可观察的行为来将误报的数量减少到最低限度。不过,诚然,尽职调查的数量因单元测试的风格而异。

But just like shallowness, brittleness is not a definitive feature of the communication-based style, either. You can reduce the number of false positives to a minimum by maintaining proper encapsulation and coupling tests to observable behavior only. Admittedly, though, the amount of due diligence varies depending on the style of unit testing.

6.2.3 使用可维护性指标比较样式

6.2.3  Comparing the styles using the metric of maintainability

最后,可维护性指标与单元测试的风格高度相关;但是,与抵制重构不同,您无能为力。可维护性评估单元测试的维护成本,由以下两个特征定义:

Finally, the maintainability metric is highly correlated with the styles of unit testing; but, unlike with resistance to refactoring, there’s not much you can do to mitigate that. Maintainability evaluates the unit tests' maintenance costs and is defined by the following two characteristics:

  • 理解测试的难易程度,这是测试规模的函数

  • How hard it is to understand the test, which is a function of the test’s size

  • 运行测试的难度,它是测试直接使用多少进程外依赖项的函数

  • How hard it is to run the test, which is a function of how many out-of-process dependencies the test works with directly

较大的测试更难维护,因为它们更难掌握或在需要时更改。同样,直接使用一个或多个进程外依赖项(例如数据库)的测试更难维护,因为您需要花时间保持这些进程外依赖项的可操作性:重新启动数据库服务器,解决网络连接问题问题等等。

Larger tests are less maintainable because they are harder to grasp or change when needed. Similarly, a test that directly works with one or several out-of-process dependencies (such as the database) is less maintainable because you need to spend time keeping those out-of-process dependencies operational: rebooting the database server, resolving network connectivity issues, and so on.

基于输出的测试的可维护性

Maintainability of output-based tests

与其他两种测试相比,基于输出的测试是最可维护的。结果测试几乎总是简短明了,因此更容易维护。基于输出的样式的这种好处源于这样一个事实,即这种样式归结为只有两件事:向方法提供输入并验证其输出,这通常只需几行代码即可完成。

Compared with the other two types of testing, output-based testing is the most maintainable. The resulting tests are almost always short and concise and thus are easier to maintain. This benefit of the output-based style stems from the fact that this style boils down to only two things: supplying an input to a method and verifying its output, which you can often do with just a couple lines of code.

由于基于输出的测试中的底层代码不得更改全局或内部状态,因此这些测试不处理进程外依赖性。因此,就可维护性特征而言,基于输出的测试是最好的。

Because the underlying code in output-based testing must not change the global or internal state, these tests don’t deal with out-of-process dependencies. Hence, output-based tests are best in terms of both maintainability characteristics.

基于状态的测试的可维护性

Maintainability of state-based tests

基于状态的测试通常比基于输出的测试更难维护。这是因为状态验证往往比输出验证占用更多的空间。这是基于状态的测试的另一个示例。

State-based tests are normally less maintainable than output-based ones. This is because state verification often takes up more space than output verification. Here’s another example of state-based testing.

清单 6.4。占用大量空间的状态验证

Listing 6.4. State verification that takes up a lot of space

[事实]
public void Adding_a_comment_to_an_article()
{
    var sut = new Article();
    var text = "注释文本";
    var author = "无名氏";
    var now = new DateTime(2019, 4, 1);

    sut.AddComment(文本, 作者, 现在);

    Assert.Equal(1, sut.Comments.Count);              
    Assert.Equal(text, sut.Comments[0].Text);         
    Assert.Equal(author, sut.Comments[0].Author);     
    Assert.Equal(now, sut.Comments[0].DateCreated);   
}
[Fact]
public void Adding_a_comment_to_an_article()
{
    var sut = new Article();
    var text = "Comment text";
    var author = "John Doe";
    var now = new DateTime(2019, 4, 1);

    sut.AddComment(text, author, now);

    Assert.Equal(1, sut.Comments.Count);             
    Assert.Equal(text, sut.Comments[0].Text);        
    Assert.Equal(author, sut.Comments[0].Author);    
    Assert.Equal(now, sut.Comments[0].DateCreated);  
}

验证文章的状态

Verifies the state of the article

此测试向文章添加评论,然后检查该评论是否出现在文章的评论列表中。尽管这个测试被简化并且只包含一个注释,但它的断言部分已经跨越了四行。基于状态的测试通常需要验证比这更多的数据,因此,其规模可能会显着增加。

This test that adds a comment to an article and then checks to see if the comment appears in the article’s list of comments. Although this test is simplified and contains just a single comment, its assertion part already spans four lines. State-based tests often need to verify much more data than that and, therefore, can grow in size significantly.

您可以通过引入隐藏大部分代码的辅助方法来缓解这个问题,从而缩短测试时间(参见清单6.5),但是这些方法需要大量的编写和维护工作。只有当这些方法将在多个测试中重复使用时,这种努力才是合理的,而这种情况很少见。我将在本书的第 3 部分中详细解释辅助方法。

You can mitigate this issue by introducing helper methods that hide most of the code and thus shorten the test (see listing 6.5), but these methods require significant effort to write and maintain. This effort is justified only when those methods are going to be reused across multiple tests, which is rarely the case. I’ll explain more about helper methods in part 3 of this book.

清单 6.5。在断言中使用辅助方法

Listing 6.5. Using helper methods in assertions

[事实]
public void Adding_a_comment_to_an_article()
{
    var sut = new Article();
    var text = "注释文本";
    var author = "无名氏";
    var now = new DateTime(2019, 4, 1);

    sut.AddComment(文本, 作者, 现在);

    sut.ShouldContainNumberOfComments(1)    
        .WithComment(文本、作者、现在);    
}
[Fact]
public void Adding_a_comment_to_an_article()
{
    var sut = new Article();
    var text = "Comment text";
    var author = "John Doe";
    var now = new DateTime(2019, 4, 1);

    sut.AddComment(text, author, now);

    sut.ShouldContainNumberOfComments(1)   
        .WithComment(text, author, now);   
}

辅助方法

Helper methods

另一种缩短基于状态的测试的方法是在被断言的类中定义相等成员。在清单6.5中,这就是Comment类。您可以将它变成一个值对象(一个类,其实例是按值而不是按引用进行比较的),如下所示;这也将简化测试,尤其是当您将它与 Fluent Assertions 等断言库结合使用时。

Another way to shorten a state-based test is to define equality members in the class that is being asserted. In listing 6.5, that’s the Comment class. You could turn it into a value object (a class whose instances are compared by value and not by reference), as shown next; this would also simplify the test, especially if you combined it with an assertion library like Fluent Assertions.

清单 6.6。 Comment按价值比较

Listing 6.6. Comment compared by value

[事实]
public void Adding_a_comment_to_an_article()
{
    var sut = new Article();
    变种评论=新评论(
        “评论文字”,
        “无名氏”,
        新日期时间(2019, 4, 1));

    sut.AddComment(comment.Text, comment.Author, comment.DateCreated);

    sut.Comments.Should().BeEquivalentTo(评论);
}
[Fact]
public void Adding_a_comment_to_an_article()
{
    var sut = new Article();
    var comment = new Comment(
        "Comment text",
        "John Doe",
        new DateTime(2019, 4, 1));

    sut.AddComment(comment.Text, comment.Author, comment.DateCreated);

    sut.Comments.Should().BeEquivalentTo(comment);
}

这个测试使用了这样一个事实,即评论可以作为整个值进行比较,而无需断言其中的各个属性。它还使用BeEquivalentToFluent Assertions 中的方法,该方法可以比较整个集合,从而无需检查集合大小。

This test uses the fact that comments can be compared as whole values, without the need to assert individual properties in them. It also uses the BeEquivalentTo method from Fluent Assertions, which can compare entire collections, thereby removing the need to check the collection size.

这是一种强大的技术,但只有当类本质上是一个并且可以转换为值对象时它才有效。否则,它会导致代码污染(使用其唯一目的是启用或简化单元测试的代码污染生产代码库)。我们将在第 11 章中讨论代码污染以及其他单元测试反模式。

This is a powerful technique, but it works only when the class is inherently a value and can be converted into a value object. Otherwise, it leads to code pollution (polluting production code base with code whose sole purpose is to enable or, as in this case, simplify unit testing). We’ll discuss code pollution along with other unit testing anti-patterns in chapter 11.

如您所见,这两种技术——使用辅助方法和将类转换为值对象——只是偶尔适用。即使这些技术适用,基于状态的测试仍然比基于输出的测试占用更多空间,因此可维护性较差。

As you can see, these two techniques — using helper methods and converting classes into value objects — are applicable only occasionally. And even when these techniques are applicable, state-based tests still take up more space than output-based tests and thus remain less maintainable.

基于通信的测试的可维护性

Maintainability of communication-based tests

在可维护性指标上,基于通信的测试得分低于基于输出和基于状态的测试。基于通信的测试需要设置测试替身和交互断言,这会占用大量空间。当你有模拟链(模拟或存根返回其他模拟,它们也返回模拟,等等,多层深)时,测试变得更大,更难维护。

Communication-based tests score worse than output-based and state-based tests on the maintainability metric. Communication-based testing requires setting up test doubles and interaction assertions, and that takes up a lot of space. Tests become even larger and less maintainable when you have mock chains (mocks or stubs returning other mocks, which also return mocks, and so on, several layers deep).

6.2.4 比较风格:结果

6.2.4  Comparing the styles: the results

现在让我们使用一个好的单元测试的属性来比较单元测试的风格。表6.1总结了比较结果。如第 6.2.1 节所述,所有三种风格在防止回归和反馈速度的指标上得分相同;因此,我在比较中省略了这些指标。

Let’s now compare the styles of unit testing using the attributes of a good unit test. Table 6.1 sums up the comparison results. As discussed in section 6.2.1, all three styles score equally with the metrics of protection against regressions and feedback speed; hence, I’m omitting these metrics from the comparison.

表 6.1。单元测试的三种风格:比较

Table 6.1. The three styles of unit testing: the comparisons

  基于输出 基于状态 基于通信

尽职调查以保持对重构的抵制

Due diligence to maintain resistance to refactoring

低的

Low

中等的

Medium

中等的

Medium

可维护性成本

Maintainability costs

低的

Low

中等的

Medium

高的

High

基于输出的测试显示了最好的结果。这种风格产生的测试很少与实现细节相结合,因此不需要太多的尽职调查来保持对重构的适当抵抗。此类测试也是最易于维护的,因为它们简洁且没有进程外依赖性。

Output-based testing shows the best results. This style produces tests that rarely couple to implementation details and thus don’t require much due diligence to maintain proper resistance to refactoring. Such tests are also the most maintainable due to their conciseness and lack of out-of-process dependencies.

基于状态和基于通信的测试在这两个指标上都更差。这些更有可能耦合到泄漏的实现细节,并且由于尺寸较大,它们还会产生更高的维护成本。

State-based and communication-based tests are worse on both metrics. These are more likely to couple to a leaking implementation detail, and they also incur higher maintenance costs due to being larger in size.

总是喜欢基于输出的测试胜过其他一切。不幸的是,说起来容易做起来难。这种单元测试风格仅适用于以函数式方式编写的代码,而对于大多数面向对象的编程语言来说,这种情况很少见。尽管如此,您仍然可以使用一些技术将更多测试转换为基于输出的风格。

Always prefer output-based testing over everything else. Unfortunately, it’s easier said than done. This style of unit testing is only applicable to code that is written in a functional way, which is rarely the case for most object-oriented programming languages. Still, there are techniques you can use to transition more of your tests toward the output-based style.

本章的其余部分展示了如何从基于状态和基于协作的测试过渡到基于输出的测试。转换要求您使代码更纯粹地发挥作用,这反过来又允许使用基于输出的测试,而不是基于状态或通信的测试。

The rest of this chapter shows how to transition from state-based and collaboration-based testing to output-based testing. The transition requires you to make your code more purely functional, which, in turn, enables the use of output-based tests instead of state- or communication-based ones.

6.3 理解功能架构

6.3  Understanding functional architecture

在我展示如何进行过渡之前,需要做一些基础工作。在本节中,您将了解什么是函数式编程和函数式架构,以及后者与六边形架构的关系。第 6.4 节使用示例说明了转换。

Some groundwork is needed before I can show how to make the transition. In this section, you’ll see what functional programming and functional architecture are and how the latter relates to the hexagonal architecture. Section 6.4 illustrates the transition using an example.

请注意,这并不是深入探讨函数式编程的主题,而是对其背后的基本原则的解释。这些基本原则应该足以理解函数式编程和基于输出的测试之间的联系。要更深入地了解函数式编程,请参阅 Scott Wlaschin 的网站和书籍,网址为https://fsharpforfunandprofit.com/books

Note that this isn’t a deep dive into the topic of functional programming, but rather an explanation of the basic principles behind it. These basic principles should be enough to understand the connection between functional programming and output-based testing. For a deeper look at functional programming, see Scott Wlaschin’s website and books at https://fsharpforfunandprofit.com/books.

6.3.1 什么是函数式编程?

6.3.1  What is functional programming?

正如我在 6.1.1 节中提到的,基于输出的单元测试风格也称为功能测试。那是因为它需要使用函数式编程以纯函数式的方式编写底层生产代码。那么,什么是函数式编程?

As I mentioned in section 6.1.1, the output-based unit testing style is also known as functional. That’s because it requires the underlying production code to be written in a purely functional way, using functional programming. So, what is functional programming?

函数式编程是使用数学函数进行编程。数学函数(也称为纯函数)是一种没有任何隐藏输入或输出的函数(或方法)。数学函数的所有输入和输出都必须在其方法签名中明确表示,方法签名由方法的名称、参数和返回类型组成。对于给定的输入,无论调用多少次,数学函数都会产生相同的输出。

Functional programming is programming with mathematical functions. A mathematical function (also known as pure function) is a function (or method) that doesn’t have any hidden inputs or outputs. All inputs and outputs of a mathematical function must be explicitly expressed in its method signature, which consists of the method’s name, arguments, and return type. A mathematical function produces the same output for a given input regardless of how many times it is called.

我们以CalculateDiscount()清单6.1中的方法为例(为了方便,我将其复制到这里):

Let’s take the CalculateDiscount() method from listing 6.1 as an example (I’m copying it here for convenience):

公共小数 CalculateDiscount(Product[] products)
{
    小数折扣 = products.Length * 0.01m;
    返回 Math.Min(折扣, 0.2m);
}
public decimal CalculateDiscount(Product[] products)
{
    decimal discount = products.Length * 0.01m;
    return Math.Min(discount, 0.2m);
}

此方法有一个输入(Product数组)和一个输出(decimal折扣),这两者都在方法的签名中明确表示。没有隐藏的输入或输出。这构成了CalculateDiscount()一个数学函数(图6.5)。

This method has one input (a Product array) and one output (the decimal discount), both of which are explicitly expressed in the method’s signature. There are no hidden inputs or outputs. This makes CalculateDiscount() a mathematical function (figure 6.5).

图 6.5。 CalculateDiscount()有一个输入(Product数组)和一个输出(decimal折扣)。输入和输出都在方法的签名中明确表示,这构成了CalculateDiscount()一个数学函数。

Figure 6.5. CalculateDiscount() has one input (a Product array) and one output (the decimal discount). Both the input and the output are explicitly expressed in the method’s signature, which makes CalculateDiscount() a mathematical function.

CH06 图5方法签名

没有隐藏输入和输出的方法称为数学函数,因为这种方法符合数学中函数的定义。

Methods with no hidden inputs and outputs are called mathematical functions because such methods adhere to the definition of a function in mathematics.

定义 6.1:

Definition 6.1:

在数学中,函数是两个集合之间的关系,对于第一个集合中的每个元素,可以在第二个集合中准确找到一个元素。

In mathematics, a function is a relationship between two sets that for each element in the first set, finds exactly one element in the second set.

6.6显示了函数如何为每个输入数字xf(x) = x + 1找到对应的数字y。图6.7显示了使用与图6.6CalculateDiscount()中相同的符号的方法。

Figure 6.6 shows how for each input number x, function f(x) = x + 1 finds a corresponding number y. Figure 6.7 displays the CalculateDiscount() method using the same notation as in figure 6.6.

图 6.6。数学中函数的一个典型例子是f(x) = x + 1。对于集合 X 中的每个输入数字x ,该函数会在集合 Y 中找到对应的数字y 。

Figure 6.6. A typical example of a function in mathematics is f(x) = x + 1. For each input number x in set X, the function finds a corresponding number y in set Y.

CH06 图6 数学函数

图 6.7。CalculateDiscount()使用与函数相同的符号表示的方法f(x) = x + 1。对于每个输入的产品数组,该方法会找到相应的折扣作为输出。

Figure 6.7. The CalculateDiscount() method represented using the same notation as the function f(x) = x + 1. For each input array of products, the method finds a corresponding discount as an output.

CH06 图7 数学函数2

显式输入和输出使数学函数极易测试,因为生成的测试简短、简单且易于理解和维护。数学函数是唯一可以应用基于输出的测试的方法类型,它具有最好的可维护性和产生误报的可能性最低。

Explicit inputs and outputs make mathematical functions extremely testable because the resulting tests are short, simple, and easy to understand and maintain. Mathematical functions are the only type of methods where you can apply output-based testing, which has the best maintainability and the lowest chance of producing a false positive.

另一方面,隐藏的输入和输出会降低代码的可测试性(以及可读性)。此类隐藏输入和输出的类型包括:

On the other hand, hidden inputs and outputs make the code less testable (and less readable, too). Types of such hidden inputs and outputs include the following:

  • 副作用 ——副作用是方法签名中未表达的输出,因此是隐藏的。当操作改变类实例的状态、更新磁盘上的文件等时,操作会产生副作用。

  • Side effects — A side effect is an output that isn’t expressed in the method signature and, therefore, is hidden. An operation creates a side effect when it mutates the state of a class instance, updates a file on the disk, and so on.

  • 异常 ——当方法抛出异常时,它会在程序流中创建一条路径,绕过方法签名建立的契约。可以在调用堆栈中的任何位置捕获抛出的异常,从而引入方法签名未传达的额外输出。

  • Exceptions — When a method throws an exception, it creates a path in the program flow that bypasses the contract established by the method’s signature. The thrown exception can be caught anywhere in the call stack, thus introducing an additional output that the method signature doesn’t convey.

  • 对内部或外部状态的引用 ——例如,一个方法可以使用静态属性(如DateTime.Now. 它可以从数据库中查询数据,也可以引用一个私有的可变字段。这些都是执行流程的输入,不存在于方法签名中,因此被隐藏。

  • A reference to an internal or external state — For example, a method can get the current date and time using a static property such as DateTime.Now. It can query data from the database, or it can refer to a private mutable field. These are all inputs to the execution flow that aren’t present in the method signature and, therefore, are hidden.

在确定方法是否为数学函数时,一个好的经验法则是查看是否可以在不更改程序行为的情况下用返回值替换对该方法的调用。用相应的值替换方法调用的能力称为引用透明性。看下面的方法,例如:

A good rule of thumb when determining whether a method is a mathematical function is to see if you can replace a call to that method with its return value without changing the program’s behavior. The ability to replace a method call with the corresponding value is known as referential transparency. Look at the following method, for example:

公共 int 增量(int x)
{
    返回 x + 1;
}
public int Increment(int x)
{
    return x + 1;
}

这个方法是一个数学函数。这两个语句是等价的:

This method is a mathematical function. These two statements are equivalent to each other:

int y =增量(4);
整数 y = 5;
int y = Increment(4);
int y = 5;

另一方面,以下方法不是数学函数。您不能用返回值替换它,因为该返回值并不代表该方法的所有输出。在此示例中,隐藏输出是对字段的更改x(副作用):

On the other hand, the following method is not a mathematical function. You can’t replace it with the return value because that return value doesn’t represent all of the method’s outputs. In this example, the hidden output is the change to field x (a side effect):

整数 x = 0;
公共整数增量()
{
    x++;
    返回 x;
}
int x = 0;
public int Increment()
{
    x++;
    return x;
}

副作用是最普遍的隐藏输出类型。下面的清单显示了一种AddComment表面上看起来像数学函数但实际上不是数学函数的方法。图6.8以图形方式显示了该方法。

Side effects are the most prevalent type of hidden outputs. The following listing shows an AddComment method that looks like a mathematical function on the surface but actually isn’t one. Figure 6.8 shows the method graphically.

清单 6.7。修改内部状态

Listing 6.7. Modification of an internal state

public Comment AddComment(字符串文本)
{
    var comment = new Comment(文本);
    _comments.Add(评论);            
    回复评论;
}
public Comment AddComment(string text)
{
    var comment = new Comment(text);
    _comments.Add(comment);            
    return comment;
}

副作用

Side effect

图 6.8。方法AddComment(显示为f)有一个text输入和一个Comment输出,它们都在方法签名中表示。副作用是一个额外的隐藏输出。

Figure 6.8. Method AddComment (shown as f) has a text input and a Comment output, which are both expressed in the method signature. The side effect is an additional hidden output.

CH06图8副作用

6.3.2 什么是功能架构?

6.3.2  What is functional architecture?

当然,您无法创建不会产生任何副作用的应用程序。这样的应用是不切实际的。毕竟,副作用是您创建所有应用程序的目的:更新用户信息、向购物车添加新的订单行等等。

You can’t create an application that doesn’t incur any side effects whatsoever, of course. Such an application would be impractical. After all, side effects are what you create all applications for: updating the user’s information, adding a new order line to the shopping cart, and so on.

函数式编程的目标不是完全消除副作用,而是在处理业务逻辑的代码和产生副作用的代码之间引入分离。这两个职责本身就足够复杂了;从长远来看,将它们混合在一起会增加复杂性并阻碍代码的可维护性。这就是功能架构发挥作用的地方。它通过将副作用推到业务操作的边缘来将业务逻辑与副作用分开。

The goal of functional programming is not to eliminate side effects altogether but rather to introduce a separation between code that handles business logic and code that incurs side effects. These two responsibilities are complex enough on their own; mixing them together multiplies the complexity and hinders code maintainability in the long run. This is where functional architecture comes into play. It separates business logic from side effects by pushing those side effects to the edges of a business operation.

定义 6.2:

Definition 6.2:

功能架构最大限度地增加了以纯功能(不可变)方式编写的代码量,同时最大限度地减少了处理副作用的代码。Immutable意味着不可更改:对象一旦创建,其状态就无法修改。这与可变对象(changeable object)相反,可变对象在创建后就可以修改。

Functional architecture maximizes the amount of code written in a purely functional (immutable) way, while minimizing code that deals with side effects. Immutable means unchangeable: once an object is created, its state can’t be modified. This is in contrast to a mutable object (changeable object), which can be modified after it is created.

业务逻辑和副作用之间的分离是通过分离两种类型的代码来完成的:

The separation between business logic and side effects is done by segregating two types of code:

  • 做出决定的代码 ——此代码不需要副作用,因此可以使用数学函数编写。

  • Code that makes a decision — This code doesn’t require side effects and thus can be written using mathematical functions.

  • 根据该决定执行的代码 ——此代码将数学函数做出的所有决定转换为可见位,例如数据库中的更改或发送到总线的消息。

  • Code that acts upon that decision — This code converts all the decisions made by the mathematical functions into visible bits, such as changes in the database or messages sent to a bus.

做出决定的代码通常被称为功能核心(也称为不可变核心)。对这些决定起作用的代码是一个可变的 shell(图6.9)。

The code that makes decisions is often referred to as a functional core (also known as an immutable core). The code that acts upon those decisions is a mutable shell (figure 6.9).

图 6.9。在功能架构中,功能核心是使用数学函数实现的,并在应用程序中做出所有决定。可变 shell 为功能核心提供输入数据,并通过对外部应用程序(如数据库)应用副作用来解释其决策。

Figure 6.9. In functional architecture, the functional core is implemented using mathematical functions and makes all decisions in the application. The mutable shell provides the functional core with input data and interprets its decisions by applying side effects to external applications such as a database.

CH06 图9 功能架构

功能核心和可变外壳以下列方式协作:

The functional core and the mutable shell cooperate in the following way:

  • 可变 shell 收集所有输入。

  • The mutable shell gathers all the inputs.

  • 功能核心产生决策。

  • The functional core generates decisions.

  • shell 将决策转化为副作用。

  • The shell converts the decisions into side effects.

为了保持这两层之间的适当分离,您需要确保表示决策的类包含足够的信息,以便可变 shell 无需额外的决策就可以对它们采取行动。换句话说,可变 shell 应该尽可能的笨。目标是通过基于输出的测试广泛覆盖功能核心,并将可变外壳留给数量少得多的集成测试。

To maintain a proper separation between these two layers, you need to make sure the classes representing the decisions contain enough information for the mutable shell to act upon them without additional decision-making. In other words, the mutable shell should be as dumb as possible. The goal is to cover the functional core extensively with output-based tests and leave the mutable shell to a much smaller number of integration tests.

封装性和不变性

Encapsulation and immutability

与封装一样,功能架构(一般而言)和不变性(特别是)与单元测试的目标相同:实现软件项目的可持续增长。事实上,封装和不变性的概念之间有着深刻的联系。

Like encapsulation, functional architecture (in general) and immutability (in particular) serve the same goal as unit testing: enabling sustainable growth of your software project. In fact, there’s a deep connection between the concepts of encapsulation and immutability.

你可能还记得第 5 章,封装是防止代码不一致的行为。封装通过以下方式保护类的内部免受损坏

As you may remember from chapter 5, encapsulation is the act of protecting your code against inconsistencies. Encapsulation safeguards the class’s internals from corruption by

  • 减少允许数据修改的 API 表面积

  • Reducing the API surface area that allows for data modification

  • 审查剩余的 API

  • Putting the remaining APIs under scrutiny

不变性从另一个角度解决了保留不变量的问题。使用不可变类,您不必担心状态损坏,因为不可能损坏一开始就无法更改的东西。因此,不需要在函数式编程中进行封装。当您创建类的实例时,您只需要验证一次类的状态。之后,您可以自由传递此实例。当您的所有数据都是不可变的时,与缺乏封装相关的整套问题就会消失。

Immutability tackles this issue of preserving invariants from another angle. With immutable classes, you don’t need to worry about state corruption because it’s impossible to corrupt something that cannot be changed in the first place. As a consequence, there’s no need for encapsulation in functional programming. You only need to validate the class’s state once, when you create an instance of it. After that, you can freely pass this instance around. When all your data is immutable, the whole set of issues related to the lack of encapsulation simply vanishes.

在这方面,迈克尔·费瑟斯 (Michael Feathers) 有一句很好的话:

There’s a great quote from Michael Feathers in that regard:

面向对象编程通过封装移动部件使代码易于理解。函数式编程通过最小化移动部件使代码易于理解。

Object-oriented programming makes code understandable by encapsulating moving parts. Functional programming makes code understandable by minimizing moving parts.

6.3.3 比较功能架构和六边形架构

6.3.3  Comparing functional and hexagonal architectures

功能和六边形架构之间有很多相似之处。它们都是围绕关注点分离的思想构建的。不过,这种分离的细节各不相同。

There are a lot of similarities between functional and hexagonal architectures. Both of them are built around the idea of separation of concerns. The details of that separation vary, though.

你可能还记得第 5 章,六边形架构区分了领域层和应用服务层(图5.9)。域负责业务逻辑,而应用程序服务层负责与外部应用程序(例如数据库或 SMTP 服务)进行通信。这与功能架构非常相似,您在其中引入了决策和操作的分离。

As you may remember from chapter 5, the hexagonal architecture differentiates the domain layer and the application services layer (figure 5.9). The domain layer is accountable for business logic while the application services layer, for communication with external applications such as a database or an SMTP service. This is very similar to functional architecture, where you introduce the separation of decisions and actions.

图 6.10。六边形架构是一组交互应用程序——六边形。您的应用程序由领域层和应用程序服务层组成,它们对应于功能架构中的功能核心和可变外壳。

Figure 6.10. Hexagonal architecture is a set of interacting applications — hexagons. Your application consists of a domain layer and an application services layer, which correspond to a functional core and a mutable shell in functional architecture.

CH05 图9六边形

另一个相似之处是依赖关系的单向流动。在六边形架构中,领域层内的类应该只相互依赖;它们不应该依赖于应用服务层的类。同样,功能架构中的不可变核心不依赖于可变外壳。它是自给自足的,可以独立于外层工作。这就是使功能架构如此可测试的原因:您可以从可变外壳中完全剥离不可变核心,并使用简单值模拟外壳提供的输入。

Another similarity is the one-way flow of dependencies. In the hexagonal architecture, classes inside the domain layer should only depend on each other; they should not depend on classes from the application services layer. Likewise, the immutable core in functional architecture doesn’t depend on the mutable shell. It’s self-sufficient and can work in isolation from the outer layers. This is what makes functional architecture so testable: you can strip the immutable core from the mutable shell entirely and simulate the inputs that the shell provides using simple values.

两者之间的区别在于它们对副作用的治疗。功能架构将所有副作用从不可变的核心推到业务运营的边缘。这些边缘由可变外壳处理。另一方面,六边形架构可以很好地处理域层产生的副作用,只要它们仅限于该域层即可。六边形架构中的所有修改都应包含在领域层内,而不是跨越该层的边界。例如,域类实例不能直接将某些东西持久化到数据库,但它可以改变自己的状态。然后应用程序服务将获取此更改并将其应用到数据库。

The difference between the two is in their treatment of side effects. Functional architecture pushes all side effects out of the immutable core to the edges of a business operation. These edges are handled by the mutable shell. On the other hand, the hexagonal architecture is fine with side effects made by the domain layer, as long as they are limited to that domain layer only. All modifications in hexagonal architecture should be contained within the domain layer and not cross that layer’s boundary. For example, a domain class instance can’t persist something to the database directly, but it can change its own state. An application service will then pick up this change and apply it to the database.

 

 

[笔记] 笔记

功能架构是六边形架构的一个子集。您可以将功能架构视为极端的六边形架构。

Functional architecture is a subset of the hexagonal architecture. You can view functional architecture as the hexagonal architecture taken to an extreme.

6.4 过渡到功能架构和基于输出的测试

6.4  Transitioning to functional architecture and output-based testing

在本节中,我们将采用示例应用程序并将其重构为功能架构。您会看到两个重构阶段:

In this section, we’ll take a sample application and refactor it toward functional architecture. You’ll see two refactoring stages:

  • 从使用进程外依赖转向使用模拟

  • Moving from using an out-of-process dependency to using mocks

  • 从使用模拟转向使用功能架构

  • Moving from using mocks to using functional architecture

转换也会影响测试代码!我们会将基于状态和基于通信的测试重构为基于输出的单元测试样式。在开始重构之前,让我们回顾一下示例项目和覆盖它的测试。

The transition affects test code, too! We’ll refactor state-based and communication-based tests to the output-based style of unit testing. Before starting the refactoring, let’s review the sample project and tests covering it.

6.4.1 引入审计系统

6.4.1  Introducing an audit system

示例项目是一个审计系统,用于跟踪组织中的所有访问者。它使用平面文本文件作为底层存储,结构如图6.11所示。系统将访问者的姓名和访问时间附加到最新文件的末尾。当达到每个文件的最大条目数时,将创建一个具有递增索引的新文件。

The sample project is an audit system that keeps track of all visitors in an organization. It uses flat text files as underlying storage with the structure shown in figure 6.11. The system appends the visitor’s name and the time of their visit to the end of the most recent file. When the maximum number of entries per file is reached, a new file with an incremented index is created.

图 6.11。审计系统以特定格式将访客信息存储在文本文件中。当达到每个文件的最大条目数时,系统会创建一个新文件。

Figure 6.11. The audit system stores information about visitors in text files with a specific format. When the maximum number of entries per file is reached, the system creates a new file.

CH06 图11 审核系统

以下清单显示了系统的初始版本。

The following listing shows the initial version of the system.

清单 6.8。审计制度初步实施

Listing 6.8. Initial implementation of the audit system

公共类 AuditManager
{
    私有只读 int _maxEntriesPerFile;
    私有只读字符串_directoryName;

    公共审计管理器(int maxEntriesPerFile,字符串目录名)
    {
        _maxEntriesPerFile = maxEntriesPerFile;
        _directoryName = 目录名;
    }

    public void AddRecord(string visitorName, DateTime timeOfVisit)
    {
        string[] filePaths = Directory.GetFiles(_directoryName);
        (int index, string path)[] sorted = SortByIndex(filePaths);

        string newRecord = visitorName + ';' + 访问时间;

        如果(已排序。长度== 0)
        {
            string newFile = Path.Combine(_directoryName, "audit_1.txt");
            File.WriteAllText(newFile, newRecord);
            返回;
        }

        (int currentFileIndex, string currentFilePath) = sorted.Last();
        列表 <字符串> 行 = File.ReadAllLines(currentFilePath).ToList();

        如果(行数 < _maxEntriesPerFile)
        {
            lines.Add(新记录);
            string newContent = string.Join("\r\n", 行);
            File.WriteAllText(currentFilePath, newContent);
        }
        别的
        {
            int newIndex = currentFileIndex + 1;
            string newName = $"audit_{newIndex}.txt";
            string newFile = Path.Combine(_directoryName, newName);
            File.WriteAllText(newFile, newRecord);
        }
    }
}
public class AuditManager
{
    private readonly int _maxEntriesPerFile;
    private readonly string _directoryName;

    public AuditManager(int maxEntriesPerFile, string directoryName)
    {
        _maxEntriesPerFile = maxEntriesPerFile;
        _directoryName = directoryName;
    }

    public void AddRecord(string visitorName, DateTime timeOfVisit)
    {
        string[] filePaths = Directory.GetFiles(_directoryName);
        (int index, string path)[] sorted = SortByIndex(filePaths);

        string newRecord = visitorName + ';' + timeOfVisit;

        if (sorted.Length == 0)
        {
            string newFile = Path.Combine(_directoryName, "audit_1.txt");
            File.WriteAllText(newFile, newRecord);
            return;
        }

        (int currentFileIndex, string currentFilePath) = sorted.Last();
        List<string> lines = File.ReadAllLines(currentFilePath).ToList();

        if (lines.Count < _maxEntriesPerFile)
        {
            lines.Add(newRecord);
            string newContent = string.Join("\r\n", lines);
            File.WriteAllText(currentFilePath, newContent);
        }
        else
        {
            int newIndex = currentFileIndex + 1;
            string newName = $"audit_{newIndex}.txt";
            string newFile = Path.Combine(_directoryName, newName);
            File.WriteAllText(newFile, newRecord);
        }
    }
}

代码可能看起来有点大,但它非常简单。AuditManager是应用程序中的主要类。它的构造函数接受每个文件的最大条目数和工作目录作为配置参数。该类中唯一的公共方法是AddRecord,它完成审计系统的所有工作:

The code might look a bit large, but it’s quite simple. AuditManager is the main class in the application. Its constructor accepts the maximum number of entries per file and the working directory as configuration parameters. The only public method in the class is AddRecord, which does all the work of the audit system:

  • 从工作目录中检索文件的完整列表

  • Retrieves a full list of files from the working directory

  • 按索引对它们进行排序(所有文件名都遵循相同的模式:audit_{index}.txt [例如,audit_1.txt])。

  • Sorts them by index (all filenames follow the same pattern: audit_{index}.txt [for example, audit_1.txt]).

  • 如果还没有审计文件,则创建第一个只有一条记录的文件

  • If there are no audit files yet, creates a first one with a single record

  • 如果有审计文件,则获取最新的文件并向其追加新记录或创建一个新文件,具体取决于该文件中的条目数是否已达到限制

  • If there are audit files, gets the most recent one and either appends the new record to it or creates a new file, depending on whether the number of entries in that file has reached the limit

该类AuditManager很难按原样进行测试,因为它与文件系统紧密耦合。测试前,您需要将文件放在正确的位置,测试完成后,您需要读取这些文件、检查其内容并清除它们(图 6.12

The AuditManager class is hard to test as is, because it’s tightly coupled to the filesystem. Before the test, you’d need to put files in the right place, and after the test finishes, you’d read those files, check their contents, and clear them out (figure 6.12).

图 6.12。涵盖审计系统初始版本的测试必须直接与文件系统一起工作。

Figure 6.12. Tests covering the initial version of the audit system would have to work directly with the filesystem.

CH06图12审计系统v1 1

您将无法并行执行此类测试 — 至少,如果没有额外的工作会显着增加维护成本。瓶颈是文件系统:它是一个共享的依赖项,测试可以通过它干扰彼此的执行流程。

You won’t be able to parallelize such tests — at least, not without additional effort that would significantly increase maintenance costs. The bottleneck is the filesystem: it’s a shared dependency through which tests can interfere with each other’s execution flow.

文件系统也使测试变慢。可维护性也会受到影响,因为您必须确保工作目录存在并且可供测试访问——无论是在本地机器上还是在构建服务器上。表6.2总结了得分。

The filesystem also makes the tests slow. Maintainability suffers, too, because you have to make sure the working directory exists and is accessible to tests — both on your local machine and on the build server. Table 6.2 sums up the scoring.

表 6.2。审计系统的初始版本在良好测试的四个属性中有两个得分很低。

Table 6.2. The initial version of the audit system scores badly on two out of the four attributes of a good test.

  初始版本

防止回归

Protection against regressions

好的

Good

抵制重构

Resistance to refactoring

好的

Good

快速反馈

Fast feedback

坏的

Bad

可维护性

Maintainability

坏的

Bad

顺便说一句,直接与文件系统一起工作的测试不符合单元测试的定义。它们不符合单元测试的第二个和第三个属性,因此属于集成测试的范畴(详见第 2 章):

By the way, tests working directly with the filesystem don’t fit the definition of a unit test. They don’t comply with the second and the third attributes of a unit test, thereby falling into the category of integration tests (see chapter 2 for more details):

  • 单元测试验证单个行为单元,

  • A unit test verifies a single unit of behavior,

  • 做得很快,

  • Does it quickly,

  • 并与其他测试隔离开来。

  • And does it in isolation from other tests.

6.4.2 使用模拟将测试与文件系统分离

6.4.2  Using mocks to decouple tests from the filesystem

紧耦合测试问题的通常解决方案是模拟文件系统。您可以将对文件的所有操作提取到一个单独的类 ( ) 中,然后通过构造函数IFileSystem将该类注入。AuditManager然后测试将模拟此类并捕获审计系统对文件所做的写入(图6.13)。

The usual solution to the problem of tightly coupled tests is to mock the filesystem. You can extract all operations on files into a separate class (IFileSystem) and inject that class into AuditManager via the constructor. The tests will then mock this class and capture the writes the audit system do to the files (figure 6.13).

图 6.13。测试可以模拟文件系统并捕获审计系统对文件所做的写入。

Figure 6.13. Tests can mock the filesystem and capture the writes the audit system makes to the files.

CH06图13审计系统v2 2

以下清单显示了文件系统是如何注入到AuditManager.

The following listing shows how the filesystem is injected into AuditManager.

清单 6.9。通过构造函数显式注入文件系统

Listing 6.9. Injecting the filesystem explicitly via the constructor

公共类 AuditManager
{
    私有只读 int _maxEntriesPerFile;
    私有只读字符串_directoryName;
    私有只读 IFileSystem _fileSystem;   

    公共审计经理(
        int maxEntriesPerFile,
        字符串目录名,
        IFileSystem 文件系统)
    {
        _maxEntriesPerFile = maxEntriesPerFile;
        _directoryName = 目录名;
        _fileSystem = 文件系统;   
    }
}
public class AuditManager
{
    private readonly int _maxEntriesPerFile;
    private readonly string _directoryName;
    private readonly IFileSystem _fileSystem;   

    public AuditManager(
        int maxEntriesPerFile,
        string directoryName,
        IFileSystem fileSystem)
    {
        _maxEntriesPerFile = maxEntriesPerFile;
        _directoryName = directoryName;
        _fileSystem = fileSystem;   
    }
}

新接口代表文件系统。

The new interface represents the filesystem.

接下来是AddRecord方法。

And next is the AddRecord method.

清单 6.10。使用新IFileSystem界面

Listing 6.10. Using the new IFileSystem interface

public void AddRecord(string visitorName, DateTime timeOfVisit)
{
    字符串 [] 文件路径 = _fileSystem    
        .GetFiles(_directoryName);     
    (int index, string path)[] sorted = SortByIndex(filePaths);

    string newRecord = visitorName + ';' + 访问时间;

    如果(已排序。长度== 0)
    {
        string newFile = Path.Combine(_directoryName, "audit_1.txt");
        _fileSystem.WriteAllText(   
            新文件、新记录);    
        返回;
    }

    (int currentFileIndex, string currentFilePath) = sorted.Last();
    列表 <字符串> 行 = _fileSystem       
        .ReadAllLines(当前文件路径);   

    如果(行数 < _maxEntriesPerFile)
    {
        lines.Add(新记录);
        string newContent = string.Join("\r\n", 行);
        _fileSystem.WriteAllText(            
            currentFilePath, newContent);   
    }
    别的
    {
        int newIndex = currentFileIndex + 1;
        string newName = $"audit_{newIndex}.txt";
        string newFile = Path.Combine(_directoryName, newName);
        _fileSystem.WriteAllText(   
            新文件、新记录);    
    }
}
public void AddRecord(string visitorName, DateTime timeOfVisit)
{
    string[] filePaths = _fileSystem   
        .GetFiles(_directoryName);     
    (int index, string path)[] sorted = SortByIndex(filePaths);

    string newRecord = visitorName + ';' + timeOfVisit;

    if (sorted.Length == 0)
    {
        string newFile = Path.Combine(_directoryName, "audit_1.txt");
        _fileSystem.WriteAllText(   
            newFile, newRecord);    
        return;
    }

    (int currentFileIndex, string currentFilePath) = sorted.Last();
    List<string> lines = _fileSystem      
        .ReadAllLines(currentFilePath);   

    if (lines.Count < _maxEntriesPerFile)
    {
        lines.Add(newRecord);
        string newContent = string.Join("\r\n", lines);
        _fileSystem.WriteAllText(           
            currentFilePath, newContent);   
    }
    else
    {
        int newIndex = currentFileIndex + 1;
        string newName = $"audit_{newIndex}.txt";
        string newFile = Path.Combine(_directoryName, newName);
        _fileSystem.WriteAllText(   
            newFile, newRecord);    
    }
}

新界面在行动

The new interface in action

在清单6.10中,IFileSystem是一个新的自定义接口,它封装了文件系统的工作:

In listing 6.10, IFileSystem is a new custom interface that encapsulates the work with the filesystem:

公共接口 IFileSystem
{
    字符串[] GetFiles(字符串目录名);
    void WriteAllText(字符串文件路径,字符串内容);
    List<string> ReadAllLines(string filePath);
}
public interface IFileSystem
{
    string[] GetFiles(string directoryName);
    void WriteAllText(string filePath, string content);
    List<string> ReadAllLines(string filePath);
}

现在它AuditManager与文件系统分离,共享依赖性消失了,测试可以相互独立执行。这是一个这样的测试。

Now that AuditManager is decoupled from the filesystem, the shared dependency is gone, and tests can execute independently from each other. Here’s one such test.

清单 6.11。使用模拟检查审计系统的行为

Listing 6.11. Checking the audit system’s behavior using a mock

[事实]
public void A_new_file_is_created_when_the_current_file_overflows()
{
    var fileSystemMock = new Mock<IFileSystem>();
    文件系统模拟
        .Setup(x => x.GetFiles("审计"))
        .返回(新字符串[]
        {
            @"audits\audit_1.txt",
            @"audits\audit_2.txt"
        });
    文件系统模拟
        .Setup(x => x.ReadAllLines(@"audits\audit_2.txt"))
        .Returns(新列表<字符串>
        {
            “彼得;2019-04-06T16:30:00”,
            “简;2019-04-06T16:40:00”,
            “杰克;2019-04-06T17:00:00”
        });
    var sut = new AuditManager(3, "审计", fileSystemMock.Object);

    sut.AddRecord("爱丽丝", DateTime.Parse("2019-04-06T18:00:00"));

    fileSystemMock.Verify(x => x.WriteAllText(
        @"audits\audit_3.txt",
        “爱丽丝;2019-04-06T18:00:00”));
}
[Fact]
public void A_new_file_is_created_when_the_current_file_overflows()
{
    var fileSystemMock = new Mock<IFileSystem>();
    fileSystemMock
        .Setup(x => x.GetFiles("audits"))
        .Returns(new string[]
        {
            @"audits\audit_1.txt",
            @"audits\audit_2.txt"
        });
    fileSystemMock
        .Setup(x => x.ReadAllLines(@"audits\audit_2.txt"))
        .Returns(new List<string>
        {
            "Peter; 2019-04-06T16:30:00",
            "Jane; 2019-04-06T16:40:00",
            "Jack; 2019-04-06T17:00:00"
        });
    var sut = new AuditManager(3, "audits", fileSystemMock.Object);

    sut.AddRecord("Alice", DateTime.Parse("2019-04-06T18:00:00"));

    fileSystemMock.Verify(x => x.WriteAllText(
        @"audits\audit_3.txt",
        "Alice;2019-04-06T18:00:00"));
}

此测试验证当当前文件中的条目数达到限制(3在本例中为 )时,将创建一个包含单个审核条目的新文件。请注意,这是模拟的合法使用。该应用程序创建最终用户可见的文件(假设这些用户使用另一个程序来读取文件,无论是专用软件还是简单的 notepad.exe)。因此,与文件系统的通信以及这些通信的副作用(即文件中的更改)是应用程序可观察行为的一部分。你可能还记得第 5 章,这是模拟的唯一合法用例。

This test verifies that when the number of entries in the current file reaches the limit (3, in this example), a new file with a single audit entry is created. Note that this is a legitimate use of mocks. The application creates files that are visible to end users (assuming that those users utilize another program to read the files, be it specialized software or a simple notepad.exe). Therefore, communications with the filesystem and the side effects of these communications (that is, the changes in files) are part of the application’s observable behavior. As you may remember from chapter 5, that’s the only legitimate use case for mocking.

这个替代实现是对初始版本的改进。由于测试不再访问文件系统,因此它们执行得更快。而且因为您不需要照看文件系统来保持测试愉快,维护成本也降低了。防止回归和抵制重构也没有受到重构的影响。表6.3显示了两个版本之间的差异。

This alternative implementation is an improvement over the initial version. Since tests no longer access the filesystem, they execute faster. And because you don’t need to look after the filesystem to keep the tests happy, the maintenance costs are also reduced. Protection against regressions and resistance to refactoring didn’t suffer from the refactoring either. Table 6.3 shows the differences between the two versions.

表 6.3。带有mocks的版本与审计系统的初始版本相比

Table 6.3. The version with mocks compared to the initial version of the audit system

  初始版本 与模拟

防止回归

Protection against regressions

好的

Good

好的

Good

抵制重构

Resistance to refactoring

好的

Good

好的

Good

快速反馈

Fast feedback

坏的

Bad

好的

Good

可维护性

Maintainability

坏的

Bad

缓和

Moderate

不过,我们仍然可以做得更好。清单6.11中的测试包含复杂的设置,这在维护成本方面不太理想。模拟库尽最大努力提供帮助,但生成的测试仍然不如那些依赖纯输入和输出的测试可读。

We can still do better, though. The test in listing 6.11 contains convoluted setups, which is less than ideal in terms of maintenance costs. Mocking libraries try their best to be helpful, but the resulting tests are still not as readable as those that rely on plain input and output.

6.4.3 重构功能架构

6.4.3  Refactoring toward functional architecture

您可以将这些副作用完全移出类AuditManager,而不是将副作用隐藏在接口后面并将该接口注入到 中。AuditManager然后只负责决定如何处理文件。一个新类 ,Persister根据该决定采取行动并将更新应用到文件系统(图6.14)。

Instead of hiding side effects behind an interface and injecting that interface into AuditManager, you can move those side effects out of the class entirely. AuditManager is then only responsible for making a decision about what to do with the files. A new class, Persister, acts on that decision and applies updates to the filesystem (figure 6.14).

图 6.14。 PersisterAuditManager形成功能架构。Persister从工作目录收集文件及其内容,将它们提供给AuditManager,然后将返回值转换为文件系统中的更改。

Figure 6.14. Persister and AuditManager form the functional architecture. Persister gathers files and their contents from the working directory, feeds them to AuditManager, and then converts the return value into changes in the filesystem.

CH06 图 14 功能版本 1

Persister在这种情况下充当可变外壳,同时AuditManager成为功能性(不可变)核心。以下清单显示了AuditManager重构后的内容。

Persister in this scenario acts as a mutable shell, while AuditManager becomes a functional (immutable) core. The following listing shows AuditManager after the refactoring.

清单 6.12。AuditManager重构后的类

Listing 6.12. The AuditManager class after refactoring

公共类 AuditManager
{
    私有只读 int _maxEntriesPerFile;

    公共审计管理器(int maxEntriesPerFile)
    {
        _maxEntriesPerFile = maxEntriesPerFile;
    }

    公共文件更新添加记录(
        FileContent[] 文件,
        字符串 visitorName,
        日期时间 timeOfVisit)
    {
        (int index, FileContent file)[] sorted = SortByIndex(files);

        string newRecord = visitorName + ';' + 访问时间;

        如果(已排序。长度== 0)
        {
            返回新的文件更新(            
                "audit_1.txt", newRecord);   
        }

        (int currentFileIndex, FileContent currentFile) = sorted.Last();
        列表 <字符串> 行 = currentFile.Lines.ToList();

        如果(行数 < _maxEntriesPerFile)
        {
            lines.Add(新记录);
            string newContent = string.Join("\r\n", 行);
            返回新的文件更新(                    
                currentFile.FileName, newContent);   
        }
        别的
        {
            int newIndex = currentFileIndex + 1;
            string newName = $"audit_{newIndex}.txt";
            返回新的文件更新(     
                新名称、新记录);   
        }
    }
}
public class AuditManager
{
    private readonly int _maxEntriesPerFile;

    public AuditManager(int maxEntriesPerFile)
    {
        _maxEntriesPerFile = maxEntriesPerFile;
    }

    public FileUpdate AddRecord(
        FileContent[] files,
        string visitorName,
        DateTime timeOfVisit)
    {
        (int index, FileContent file)[] sorted = SortByIndex(files);

        string newRecord = visitorName + ';' + timeOfVisit;

        if (sorted.Length == 0)
        {
            return new FileUpdate(           
                "audit_1.txt", newRecord);   
        }

        (int currentFileIndex, FileContent currentFile) = sorted.Last();
        List<string> lines = currentFile.Lines.ToList();

        if (lines.Count < _maxEntriesPerFile)
        {
            lines.Add(newRecord);
            string newContent = string.Join("\r\n", lines);
            return new FileUpdate(                   
                currentFile.FileName, newContent);   
        }
        else
        {
            int newIndex = currentFileIndex + 1;
            string newName = $"audit_{newIndex}.txt";
            return new FileUpdate(     
                newName, newRecord);   
        }
    }
}

返回更新指令

Returns an update instruction

而不是工作目录路径,AuditManager现在接受一个数组FileContent。此类包括AuditManager做出决定所需的有关文件系统的所有信息:

Instead of the working directory path, AuditManager now accepts an array of FileContent. This class includes everything AuditManager needs to know about the filesystem to make a decision:

公共类 FileContent
{
    公共只读字符串文件名;
    public readonly string[] 行;

    公共文件内容(字符串文件名,字符串[]行)
    {
        文件名=文件名;
        线条=线条;
    }
}
public class FileContent
{
    public readonly string FileName;
    public readonly string[] Lines;

    public FileContent(string fileName, string[] lines)
    {
        FileName = fileName;
        Lines = lines;
    }
}

而且,现在不再改变工作目录中的文件,而是AuditManager返回一条指令,说明它想要执行的副作用:

And, instead of mutating files in the working directory, AuditManager now returns an instruction for the side effect it would like to perform:

公共类文件更新
{
    公共只读字符串文件名;
    公共只读字符串 NewContent;

    公共文件更新(字符串文件名,字符串新内容)
    {
        文件名=文件名;
        新内容=新内容;
    }
}
public class FileUpdate
{
    public readonly string FileName;
    public readonly string NewContent;

    public FileUpdate(string fileName, string newContent)
    {
        FileName = fileName;
        NewContent = newContent;
    }
}

以下清单显示了该类Persister

The following listing shows the Persister class.

清单 6.13。AuditManager根据决定执行的可变外壳

Listing 6.13. The mutable shell acting on AuditManager`s decision

公开课坚持
{
    public FileContent[] ReadDirectory(string directoryName)
    {
        返回目录
            .GetFiles(目录名)
            .Select(x => 新文件内容(
                路径.GetFileName(x),
                文件.ReadAllLines(x)))
            .ToArray();
    }

    public void ApplyUpdate(string directoryName, FileUpdate update)
    {
        string filePath = Path.Combine(directoryName, update.FileName);
        File.WriteAllText(文件路径, update.NewContent);
    }
}
public class Persister
{
    public FileContent[] ReadDirectory(string directoryName)
    {
        return Directory
            .GetFiles(directoryName)
            .Select(x => new FileContent(
                Path.GetFileName(x),
                File.ReadAllLines(x)))
            .ToArray();
    }

    public void ApplyUpdate(string directoryName, FileUpdate update)
    {
        string filePath = Path.Combine(directoryName, update.FileName);
        File.WriteAllText(filePath, update.NewContent);
    }
}

注意这个类是多么的琐碎。它所做的只是从工作目录中读取内容并将其从中接收到的更新应用AuditManager回该工作目录。它没有分支(没有if语句);所有的复杂性都在AuditManager类中。这就是业务逻辑和 action 中的副作用的分离

Notice how trivial this class is. All it does is read content from the working directory and apply updates it receives from AuditManager back to that working directory. It has no branching (no if statements); all the complexity resides in the AuditManager class. This is the separation between business logic and side effects in action.

要保持这种分离,您需要保持框架的内置文件交互命令的界面FileContent并尽可能接近该界面。FileUpdate所有的解析和准备工作都应该在功能核心中完成,这样核心之外的代码就会变得微不足道。File.ReadAllLines()例如,如果 .NET 不包含将文件内容作为行数组返回的内置方法,并且只有File.ReadAllText(),它返回单个字符串,则您需要将属性替换LinesFileContenttoostring和做解析AuditManager

To maintain such a separation, you need to keep the interface of FileContent and FileUpdate as close as possible to that of the framework’s built-in file-interaction commands. All the parsing and preparation should be done in the functional core, so that the code outside of that core remains trivial. For example, if .NET didn’t contain the built-in File.ReadAllLines() method, which returns the file content as an array of lines, and only has File.ReadAllText(), which returns a single string, you’d need to replace the Lines property in FileContent with a string too and do the parsing in AuditManager:

公共类 FileContent
{
    公共只读字符串文件名;
    公共只读字符串文本;// 之前,string[] 行;
}
public class FileContent
{
    public readonly string FileName;
    public readonly string Text; // previously, string[] Lines;
}

要将AuditManager和粘合Persister在一起,您需要另一个类:六边形体系结构分类法中的应用程序服务。

To glue AuditManager and Persister together, you need another class: an application service in the hexagonal architecture taxonomy.

清单 6.14。将功能核心和可变外壳粘合在一起

Listing 6.14. Gluing together the functional core and mutable shell

公共类ApplicationService
{
    私有只读字符串_directoryName;
    私有只读 AuditManager _auditManager;
    私有只读持久性_persister;

    公共应用服务(
        字符串目录名,int maxEntriesPerFile)
    {
        _directoryName = 目录名;
        _auditManager = new AuditManager(maxEntriesPerFile);
        _persister = new 持久性();
    }

    public void AddRecord(string visitorName, DateTime timeOfVisit)
    {
        FileContent[] files = _persister.ReadDirectory(_directoryName);
        文件更新更新 = _auditManager.AddRecord(
            文件、访问者姓名、访问时间);
        _persister.ApplyUpdate(_directoryName, 更新);
    }
}
public class ApplicationService
{
    private readonly string _directoryName;
    private readonly AuditManager _auditManager;
    private readonly Persister _persister;

    public ApplicationService(
        string directoryName, int maxEntriesPerFile)
    {
        _directoryName = directoryName;
        _auditManager = new AuditManager(maxEntriesPerFile);
        _persister = new Persister();
    }

    public void AddRecord(string visitorName, DateTime timeOfVisit)
    {
        FileContent[] files = _persister.ReadDirectory(_directoryName);
        FileUpdate update = _auditManager.AddRecord(
            files, visitorName, timeOfVisit);
        _persister.ApplyUpdate(_directoryName, update);
    }
}

除了将功能核心与可变外壳粘合在一起之外,应用程序服务还为外部客户端提供了一个系统入口点(图6.15)。有了这个实现,检查审计系统的行为就变得容易了。所有测试现在归结为提供工作目录的假设状态并​​验证决策AuditManager

Along with gluing the functional core together with the mutable shell, the application service also provides an entry point to the system for external clients (figure 6.15). With this implementation, it becomes easy to check the audit system’s behavior. All tests now boil down to supplying a hypothetical state of the working directory and verifying the decision AuditManager makes.

图 6.15。 ApplicationService将功能核心 ( AuditManager) 和可变外壳 ( Persister) 粘合在一起,并为外部客户端提供入口点。在六边形架构分类法中,ApplicationService属于Persister应用服务层,AuditManager属于领域模型。

Figure 6.15. ApplicationService glues the functional core (AuditManager) and the mutable shell (Persister) together and provides an entry point for external clients. In the hexagonal architecture taxonomy, ApplicationService and Persister are part of the application services layer, while AuditManager belongs to the domain model.

CH06 图15 功能版本2

清单 6.15。没有模拟的测试

Listing 6.15. The test without mocks

[事实]
public void A_new_file_is_created_when_the_current_file_overflows()
{
    var sut = new AuditManager(3);
    var 文件 = 新文件内容[]
    {
        new FileContent("audit_1.txt", new string[0]),
        新文件内容(“audit_2.txt”,新字符串[]
        {
            “彼得;2019-04-06T16:30:00”,
            “简;2019-04-06T16:40:00”,
            “杰克;2019-04-06T17:00:00”
        })
    };

    FileUpdate update = sut.AddRecord(
        文件,“爱丽丝”,DateTime.Parse(“2019-04-06T18:00:00”));

    Assert.Equal("audit_3.txt", update.FileName);
    Assert.Equal("爱丽丝;2019-04-06T18:00:00", update.NewContent);
}
[Fact]
public void A_new_file_is_created_when_the_current_file_overflows()
{
    var sut = new AuditManager(3);
    var files = new FileContent[]
    {
        new FileContent("audit_1.txt", new string[0]),
        new FileContent("audit_2.txt", new string[]
        {
            "Peter; 2019-04-06T16:30:00",
            "Jane; 2019-04-06T16:40:00",
            "Jack; 2019-04-06T17:00:00"
        })
    };

    FileUpdate update = sut.AddRecord(
        files, "Alice", DateTime.Parse("2019-04-06T18:00:00"));

    Assert.Equal("audit_3.txt", update.FileName);
    Assert.Equal("Alice;2019-04-06T18:00:00", update.NewContent);
}

该测试保留了对初始版本进行模拟测试的改进(快速反馈),但也进一步改进了可维护性指标。不再需要复杂的模拟设置,只需要简单的输入和输出,这对测试的可读性有很大帮助。表6.4比较了基于输出的测试与初始版本和模拟版本。

This test retains the improvement the test with mocks made over the initial version (fast feedback) but also further improves on the maintainability metric. There’s no need for complex mock setups anymore, only plain inputs and outputs, which helps the test’s readability a lot. Table 6.4 compares the output-based test with the initial version and the version with mocks.

表 6.4。基于输出的测试与前两个版本相比

Table 6.4. The output-based test compared to the previous two versions

  初始版本 与模拟 基于输出

防止回归

Protection against regressions

好的

Good

好的

Good

好的

Good

抵制重构

Resistance to refactoring

好的

Good

好的

Good

好的

Good

快速反馈

Fast feedback

坏的

Bad

好的

Good

好的

Good

可维护性

Maintainability

坏的

Bad

缓和

Moderate

好的

Good

请注意,功能内核生成的指令始终是一个或一组值。这种值的两个实例可以互换,只要它们的内容匹配。您可以利用这一事实并通过转换FileUpdate为值对象来进一步提高测试的可读性。要在 .NET 中执行此操作,您需要将类转换为 astruct或定义自定义相等成员。这将为您提供按值比较,而不是按引用比较,后者是 C# 中类的默认行为。按值比较还允许您将清单6.15中的两个断言压缩为一个:

Notice that the instructions generated by a functional core are always a value or a set of values. Two instances of such a value are interchangeable as long as their contents match. You can take advantage of this fact and improve test readability even further by turning FileUpdate into a value object. To do that in .NET, you need to either convert the class into a struct or define custom equality members. That will give you comparison by value, as opposed to the comparison by reference, which is the default behavior for classes in C#. Comparison by value also allows you to compress the two assertions from listing 6.15 into one:

断言.等于(
    新文件更新(“audit_3.txt”,“爱丽丝;2019-04-06T18:00:00”),
    更新);
Assert.Equal(
    new FileUpdate("audit_3.txt", "Alice;2019-04-06T18:00:00"),
    update);

或者,使用 Fluent Assertions,

Or, using Fluent Assertions,

update.Should().Be(
    new FileUpdate("audit_3.txt", "Alice;2019-04-06T18:00:00"));
update.Should().Be(
    new FileUpdate("audit_3.txt", "Alice;2019-04-06T18:00:00"));

6.4.4 期待进一步发展

6.4.4  Looking forward to further developments

让我们退后一步,看看可以在我们的示例项目中完成的进一步开发。我向您展示的审计系统非常简单,只包含三个分支:

Let’s step back for a minute and look at further developments that could be done in our sample project. The audit system I showed you is quite simple and contains only three branches:

  • 在工作目录为空的情况下创建新文件

  • Creating a new file in case of an empty working directory

  • 将新记录附加到现有文件

  • Appending a new record to an existing file

  • 当当前文件中的条目数超过限制时创建另一个文件

  • Creating another file when the number of entries in the current file exceeds the limit

此外,只有一个用例:向审计日志添加新条目。如果还有其他用例,例如删除所有提及特定访问者的内容怎么办?如果系统需要进行验证(例如,访问者姓名的最大长度)怎么办?

Also, there’s only one use case: addition of a new entry to the audit log. What if there were another use case, such as deleting all mentions of a particular visitor? And what if the system needed to do validations (say, for the maximum length of the visitor’s name)?

删除特定访问者的所有提及可能会影响多个文件,因此新方法需要返回多个文件指令:

Deleting all mentions of a particular visitor could potentially affect several files, so the new method would need to return multiple file instructions:

公共 FileUpdate[] DeleteAllMentions(
    FileContent[] 文件,字符串 visitorName)
public FileUpdate[] DeleteAllMentions(
    FileContent[] files, string visitorName)

此外,业务人员可能要求您不要在工作目录中保留空文件。如果删除的条目是审核文件中的最后一个条目,则您需要完全删除该文件。要实现此要求,您可以重命名FileUpdateFileAction引入一个额外的ActionType枚举字段来指示它是更新还是删除。

Furthermore, business people might require that you not keep empty files in the working directory. If the deleted entry was the last entry in an audit file, you would need to remove that file altogether. To implement this requirement, you could rename FileUpdate to FileAction and introduce an additional ActionType enum field to indicate whether it was an update or a deletion.

功能架构的错误处理也变得更简单和更明确。您可以将错误嵌入到方法的签名中,在FileUpdate类中或作为单独的组件:

Error handling also becomes simpler and more explicit with functional architecture. You could embed errors into the method’s signature, either in the FileUpdate class or as a separate component:

public (FileUpdate 更新, Error错误) AddRecord(
    FileContent[] 文件,
    字符串 visitorName,
    日期时间 timeOfVisit)
public (FileUpdate update, Error error) AddRecord(
    FileContent[] files,
    string visitorName,
    DateTime timeOfVisit)

然后应用程序服务会检查这个错误。如果它在那里,服务就不会将更新指令传递给持久化者,而是向用户传播一条错误消息。

The application service would then check for this error. If it was there, the service wouldn’t pass the update instruction to the persister, instead propagating an error message to the user.

6.5 了解功能架构的缺点

6.5  Understanding the drawbacks of functional architecture

不幸的是,功能架构并不总是可以实现的。即使是这样,可维护性的好处也常常被性能影响和代码库大小的增加所抵消。在本节中,我们将探讨功能架构的成本和权衡。

Unfortunately, functional architecture isn’t always attainable. And even when it is, the maintainability benefits are often offset by a performance impact and increase in the size of the code base. In this section, we’ll explore the costs and the trade-offs attached to functional architecture.

6.5.1 功能架构的适用性

6.5.1  Applicability of functional architecture

功能架构适用于我们的审计系统,因为该系统可以在做出决定之前预先收集所有输入。但是,执行流程通常并不那么直接。您可能需要根据决策过程的中间结果从进程外依赖项中查询其他数据。

Functional architecture worked for our audit system because this system could gather all the inputs up front, before making a decision. Often, though, the execution flow is less straightforward. You might need to query additional data from an out-of-process dependency, based on an intermediate result of the decision-making process.

这是一个例子。假设审计系统需要检查访问者的访问级别,如果他们在过去 24 小时内访问的次数超过某个阈值。我们还假设所有访问者的访问级别都存储在数据库中。你不能像这样传递一个IDatabase实例AuditManager

Here’s an example. Let’s say the audit system needs to check the visitor’s access level if the number of times they have visited during the last 24 hours exceeds some threshold. And let’s also assume that all visitors' access levels are stored in a database. You can’t pass an IDatabase instance to AuditManager like this:

公共文件更新添加记录(
    FileContent[] 文件,字符串 visitorName,
    DateTime timeOfVisit,IDatabase 数据库
public FileUpdate AddRecord(
    FileContent[] files, string visitorName,
    DateTime timeOfVisit, IDatabase database)

这样的实例会向该方法引入隐藏的输入AddRecord()。因此,该方法将不再是数学函数(图6.16),这意味着您将不再能够应用基于输出的测试。

Such an instance would introduce a hidden input to the AddRecord() method. This method would, therefore, cease to be a mathematical function (figure 6.16), which means you would no longer be able to apply output-based testing.

图 6.16。对数据库的依赖引入了对AuditManager. 这样的类不再是纯函数式的,整个应用也不再遵循函数式架构。

Figure 6.16. A dependency on the database introduces a hidden input to AuditManager. Such a class is no longer purely functional, and the whole application no longer follows the functional architecture.

CH06 图16 缺点1

这种情况有两种解决方法:

There are two solutions in such a situation:

  • 您可以预先收集访问者在应用程序服务中的访问级别以及目录内容。

  • You can gather the visitor’s access level in the application service up front, along with the directory content.

  • 您可以引入一种新方法,例如IsAccessLevelCheckRequired()in AuditManager。应用程序服务之前会调用此方法AddRecord(),如果它返回true,该服务将从数据库中获取访问级别并将其传递给AddRecord()

  • You can introduce a new method such as IsAccessLevelCheckRequired() in AuditManager. The application service would call this method before AddRecord(), and if it returned true, the service would get the access level from the database and pass it to AddRecord().

两种方法都有缺点。第一个牺牲了性能——它无条件地查询数据库,即使在不需要访问级别的情况下也是如此。但这种方法保持了业务逻辑的分离以及与外部系统的通信完全完好无损:所有决策都AuditManager像以前一样驻留在内部。第二种方法承认一定程度的分离以提高性能:关于是否调用数据库的决定现在交给应用程序服务,而不是AuditManager

Both approaches have drawbacks. The first one concedes performance — it unconditionally queries the database, even in cases when the access level is not required. But this approach keeps the separation of business logic and communication with external systems fully intact: all decision-making resides in AuditManager as before. The second approach concedes a degree of that separation for performance gains: the decision as to whether to call the database now goes to the application service, not AuditManager.

请注意,与这两个选项不同,使域模型 ( AuditManager) 依赖于数据库并不是一个好主意。我将在接下来的两章中详细解释如何在性能和关注点分离之间保持平衡。

Note that, unlike these two options, making the domain model (AuditManager) depend on the database isn’t a good idea. I’ll explain more about keeping the balance between performance and separation of concerns in the next two chapters.

合作者与价值观

Collaborators vs. values

您可能已经注意到 的AuditManager方法AddRecord()具有其签名中不存在的依赖项:字段_maxEntriesPerFile。审计经理参考这个字段来决定是附加一个现有的审计文件还是创建一个新的。

You may have noticed that AuditManager's AddRecord() method has a dependency that’s not present in its signature: the _maxEntriesPerFile field. The audit manager refers to this field to make a decision to either append an existing audit file or create a new one.

虽然这种依赖性不存在于方法的参数中,但它并没有被隐藏。它可以从类的构造函数签名派生。而且因为该_maxEntriesPerFile字段是不可变的,它在类实例化和对AddRecord(). 换句话说,该字段是一个

Although this dependency isn’t present among the method’s arguments, it’s not hidden. It can be derived from the class’s constructor signature. And because the _maxEntriesPerFile field is immutable, it stays the same between the class instantiation and the call to AddRecord(). In other words, that field is a value.

依赖关系的情况IDatabase不同,因为它是一个collaborator,而不是像_maxEntriesPerFile. 您可能还记得第 2 章中的内容,协作者是一种依赖关系,它是以下其中一项:

The situation with the IDatabase dependency is different because it’s a collaborator, not a value like _maxEntriesPerFile. As you may remember from chapter 2, a collaborator is a dependency that is one or the other of the following:

  • 可变(允许修改其状态)

  • Mutable (allows for modification of its state)

  • 尚未在内存中的数据的代理(共享依赖项)

  • A proxy to data that is not yet in memory (a shared dependency)

IDatabase实例属于第二类,因此是协作者。它需要额外调用进程外依赖项,因此排除了使用基于输出的测试。

The IDatabase instance falls into the second category and, therefore, is a collaborator. It requires an additional call to an out-of-process dependency and thus precludes the use of output-based testing.

来自功能核心的 V03A 类不应与协作者一起工作,而应与其工作的产品一起工作,即价值。

V03A class from the functional core should work not with a collaborator, but with the product of its work, a value.

6.5.2 性能缺陷

6.5.2  Performance drawbacks

对整个系统的性能影响是反对功能架构的常见论据。请注意,受影响的不是测试的性能。我们最终使用的基于输出的测试与使用模拟的测试一样快。而是系统本身现在必须对进程外依赖项进行更多调用,从而导致性能下降。审计系统的初始版本没有从工作目录中读取所有文件,带有模拟的版本也没有。但最终版本确实是为了符合阅读-决定-行动的方法。

The performance impact on the system as a whole is a common argument against functional architecture. Note that it’s not the performance of tests that suffers. The output-based tests we ended up with work as fast as the tests with mocks. It’s that the system itself now has to do more calls to out-of-process dependencies and becomes less performant. The initial version of the audit system didn’t read all files from the working directory, and neither did the version with mocks. But the final version does in order to comply with the read-decide-act approach.

在功能架构和更传统的架构之间进行选择是在性能和​​代码可维护性(生产代码和测试代码)之间进行权衡。在一些性能影响不那么明显的系统中,最好使用功能架构以获得可维护性的额外收益。在其他情况下,您可能需要做出相反的选择。没有放之四海而皆准的解决方案。

The choice between a functional architecture and a more traditional one is a trade-off between performance and code maintainability (both production and test code). In some systems where the performance impact is not as noticeable, it’s better to go with functional architecture for additional gains in maintainability. In others, you might need to make the opposite choice. There’s no one-size-fits-all solution.

6.5.3 增加代码库大小

6.5.3  Increase in the code base size

代码库的大小也是如此。功能架构需要功能(不可变)核心和可变外壳之间的明确分离。这最初需要额外的编码,尽管它最终会降低代码的复杂性并提高可维护性。

The same is true for the size of the code base. Functional architecture requires a clear separation between the functional (immutable) core and the mutable shell. This necessitates additional coding initially, although it ultimately results in reduced code complexity and gains in maintainability.

不过,并非所有项目都表现出足够高的复杂性来证明这样的初始投资是合理的。一些代码库从业务角度来看并不那么重要,或者只是太简单了。在此类项目中使用功能架构没有意义,因为初始投资永远不会得到回报。始终战略性地应用功能架构,同时考虑系统的复杂性和重要性。

Not all projects exhibit a high enough degree of complexity to justify such an initial investment, though. Some code bases aren’t that significant from a business perspective or are just plain too simple. It doesn’t make sense to use functional architecture in such projects because the initial investment will never pay off. Always apply functional architecture strategically, taking into account the complexity and importance of your system.

最后,如果纯粹的代价太高,请不要追求纯粹的功能方法。在大多数项目中,您无法使域模型完全不可变,因此不能完全依赖基于输出的测试,至少在使用 C# 或 Java 等 OOP 语言时不能。在大多数情况下,您将结合使用基于输出和基于状态的样式,以及少量基于通信的测试,这很好。本章的目标不是鼓励您将所有测试转换为基于输出的风格;目标是尽可能多地过渡他们。区别很微妙但很重要。

Finally, don’t go for purity of the functional approach if that purity comes at a too high a cost. In most projects, you won’t be able to make the domain model fully immutable and thus can’t rely solely on output-based tests, at least not when using an OOP language like C# or Java. In most cases, you’ll have a combination of output-based and state-based styles, with a small mix of communication-based tests, and that’s fine. The goal of this chapter is not to incite you to transition all your tests toward the output-based style; the goal is to transition as many of them as reasonably possible. The difference is subtle but important.

6.6 总结

6.6  Summary

  • 基于输出的测试是一种测试方式,您将输入提供给 SUT 并检查它产生的输出。这种测试方式假定没有隐藏的输入或输出,并且 SUT 工作的唯一结果是它返回的值。

  • Output-based testing is a style of testing where you feed an input to the SUT and check the output it produces. This style of testing assumes there are no hidden inputs or outputs, and the only result of the SUT’s work is the value it returns.

  • 基于状态的测试在操作完成后验证系统的状态。

  • State-based testing verifies the state of the system after an operation is completed.

  • 基于通信的测试中,您使用模拟来验证被测系统与其协作者之间的通信。

  • In communication-based testing, you use mocks to verify communications between the system under test and its collaborators.

  • 经典的单元测试学派更喜欢基于状态的风格而不是基于通信的风格。伦敦学校有相反的偏好。两所学校都使用基于输出的测试。

  • The classical school of unit testing prefers the state-based style over the communication-based one. The London school has the opposite preference. Both schools use output-based testing.

  • 基于输出的测试产生最高质量的测试。此类测试很少与实现细节相结合,因此难以重构。它们也小而简洁,因此更易于维护。

  • Output-based testing produces tests of the highest quality. Such tests rarely couple to implementation details and thus are resistant to refactoring. They are also small and concise and thus are more maintainable.

  • 基于状态的测试需要格外谨慎以避免脆弱:您需要确保不公开私有状态来启用单元测试。因为基于状态的测试往往比基于输出的测试更大,所以它们也更难维护。使用辅助方法和值对象有时可以减轻(但不能消除)可维护性问题。

  • State-based testing requires extra prudence to avoid brittleness: you need to make sure you don’t expose a private state to enable unit testing. Because state-based tests tend to be larger than output-based tests, they are also less maintainable. Maintainability issues can sometimes be mitigated (but not eliminated) with the use of helper methods and value objects.

  • 基于通信的测试还需要格外谨慎以避免脆弱性。您应该只验证跨越应用程序边界且其副作用对外部世界可见的通信。与基于输出和基于状态的测试相比,基于通信的测试的可维护性更差。模拟往往会占用大量空间,这会降低测试的可读性。

  • Communication-based testing also requires extra prudence to avoid brittleness. You should only verify communications that cross the application boundary and whose side effects are visible to the external world. Maintainability of communication-based tests is worse compared to output-based and state-based tests. Mocks tend to occupy a lot of space, and that makes tests less readable.

  • 函数式编程是使用数学函数进行编程。

  • Functional programming is programming with mathematical functions.

  • 数学函数是没有任何隐藏输入或输出的函数(或方法)。副作用和异常是隐藏的输出。对内部或外部状态的引用是隐藏输入。数学函数是明确的,这使得它们极易测试。

  • A mathematical function is a function (or method) that doesn’t have any hidden inputs or outputs. Side effects and exceptions are hidden outputs. A reference to an internal or external state is a hidden input. Mathematical functions are explicit, which makes them extremely testable.

  • 函数式编程的目标是引入业务逻辑和副作用之间的分离。

  • The goal of functional programming is to introduce a separation between business logic and side effects.

  • 功能架构通过将副作用推到业务运营的边缘来帮助实现这种分离。这种方法最大限度地增加了以纯函数方式编写的代码量,同时最大限度地减少了处理副作用的代码。

  • Functional architecture helps achieve that separation by pushing side effects to the edges of a business operation. This approach maximizes the amount of code written in a purely functional way while minimizing code that deals with side effects.

  • 功能架构将所有代码分为两类:功能核心和可变外壳。功能核心做出决定。可变外壳向功能核心提供输入数据,并将核心做出的决定转化为副作用。

  • Functional architecture divides all code into two categories: functional core and mutable shell. The functional core makes decisions. The mutable shell supplies input data to the functional core and converts decisions the core makes into side effects.

  • 功能架构和六边形架构之间的区别在于它们对副作用的处理。功能架构将所有副作用推出领域层。相反,六边形架构可以很好地处理域层产生的副作用,只要它们仅限于该域层即可。功能架构是六边形架构的极致。

  • The difference between functional and hexagonal architectures is in their treatment of side effects. Functional architecture pushes all side effects out of the domain layer. Conversely, hexagonal architecture is fine with side effects made by the domain layer, as long as they are limited to that domain layer only. Functional architecture is hexagonal architecture taken to an extreme.

  • 在功能架构和更传统的架构之间进行选择是性能和代码可维护性之间的权衡。功能架构为了可维护性的提高而牺牲性能。

  • The choice between a functional architecture and a more traditional one is a trade-off between performance and code maintainability. Functional architecture concedes performance for maintainability gains.

  • 并非所有代码库都值得转换为功能架构。战略性地应用功能架构。考虑系统的复杂性和重要性。在简单或不那么重要的代码库中,功能架构所需的初始投资不会得到回报。

  • Not all code bases are worth converting into functional architecture. Apply functional architecture strategically. Take into account the complexity and the importance of your system. In code bases that are simple or not that important, the initial investment required for functional architecture won’t pay off.

7

7

重构有价值的单元测试

Refactoring toward valuable unit tests

本章涵盖:

This chapter covers:

  • 识别四种类型的代码

  • Recognizing the four types of code

  • 理解低级对象模式

  • Understanding the Humble Object pattern

  • 编写有价值的测试

  • Writing valuable tests

在第 1 章中,我定义了一个好的单元测试套件的属性:

In chapter 1, I defined the properties of a good unit test suite:

  • 它被集成到开发周期中。

  • It is integrated into the development cycle.

  • 它只针对代码库中最重要的部分。

  • It targets only the most important parts of your code base.

  • 它以最低的维护成本提供最大的价值。要实现最后一个属性,您需要能够:

    • 识别有价值的测试(并且,通过扩展,低价值的测试)。

    • 写一个有价值的测试。

  • It provides maximum value with minimum maintenance costs. To achieve this last attribute, you need to be able to:

    • Recognize a valuable test (and, by extension, a test of low value).

    • Write a valuable test.

第 4 章涵盖了使用四个属性识别有价值的测试的主题:防止回归、抗重构、快速反馈和可维护性。第 5 章扩展了四个中最重要的一个:抵制重构。

Chapter 4 covered the topic of recognizing a valuable test using the four attributes: protection against regressions, resistance to refactoring, fast feedback, and maintainability. And chapter 5 expanded on the most important one of the four: resistance to refactoring.

正如我之前提到的,仅仅识别有价值的测试是不够的,您还应该能够编写这样的测试。后一种技能需要前者,但它还需要您了解代码设计技术。单元测试和底层代码高度交织在一起,如果不对它们所涵盖的代码库投入精力,就不可能创建有价值的测试。

As I mentioned earlier, it’s not enough to recognize valuable tests, you should also be able to write such tests. The latter skill requires the former, but it also requires that you know code design techniques. Unit tests and the underlying code are highly intertwined, and it’s impossible to create valuable tests without putting effort into the code base they cover.

您在第 6 章中看到了代码库转换的示例,其中我们将审计系统重构为功能架构,因此能够应用基于输出的测试。本章将这种方法推广到更广泛的应用程序,包括那些不能使用功能架构的应用程序。您将看到有关如何在几乎所有软件项目中编写有价值的测试的实用指南。

You saw an example of a code base transformation in chapter 6, where we refactored an audit system toward a functional architecture and, as a result, were able to apply output-based testing. This chapter generalizes this approach onto a wider spectrum of applications, including those that can’t use a functional architecture. You’ll see practical guidelines on how to write valuable tests in almost any software project.

7.1 确定要重构的代码

7.1  Identifying the code to refactor

如果不重构底层代码,几乎不可能显着改进测试套件。没有办法绕过它——测试和生产代码本质上是联系在一起的。在本节中,您将看到如何将您的代码分为四种类型,以便概述重构的方向。随后的部分显示了一个综合示例。

It’s rarely possible to significantly improve a test suite without refactoring the underlying code. There’s no way around it — test and production code are intrinsically connected. In this section, you’ll see how to categorize your code into the four types in order to outline the direction of the refactoring. The subsequent sections show a comprehensive example.

7.1.1 四种代码

7.1.1  The four types of code

在本节中,我将描述四种类型的代码,它们将作为本章其余部分的基础。所有生产代码都可以按两个维度进行分类:

In this section, I describe the four types of code that serve as a foundation for the rest of this chapter. All production code can be categorized along two dimensions:

  • 复杂性或领域意义

  • Complexity or domain significance

  • 协作者数量

  • The number of collaborators

代码复杂性由代码中决策(分支)点的数量定义。这个数字越大,复杂度越高。

Code complexity is defined by the number of decision-making (branching) points in the code. The greater that number, the higher the complexity.

如何计算圈复杂度

How to calculate cyclomatic complexity

在计算机科学中,有一个专门的术语来描述代码的复杂性:圈复杂度。圈复杂度表示给定程序或方法中的分支数。该指标计算为

In computer science, there’s a special term that describes code complexity: cyclomatic complexity. Cyclomatic complexity indicates the number of branches in a given program or method. This metric is calculated as

1 + <分支点数>
1 + <number of branching points>

因此,没有控制流语句(例如if语句或条件循环)的方法的圈复杂度为 1 + 0 = 1。

Thus, a method with no control flow statements (such as if statements or conditional loops) has a cyclomatic complexity of 1 + 0 = 1.

这个指标还有另一个含义。您可以根据方法从入口到出口的独立路径数,或获得 100% 分支覆盖率所需的测试数来考虑它。

There’s another meaning to this metric. You can think of it in terms of the number of independent paths through the method from an entry to an exit, or the number of tests needed to get a 100% branch coverage.

请注意,分支点的数量被计算为涉及的最简单谓词的数量。例如,语句 likeIF condition1 AND condition2 THEN …等同于IF condition1 THEN IF condition2 THEN …. 因此,其复杂度为1 + 2 = 3

Note that the number of branching points is counted as the number of simplest predicates involved. For instance, a statement like IF condition1 AND condition2 THEN … is equivalent to IF condition1 THEN IF condition2 THEN …. Therefore, its complexity would be 1 + 2 = 3.

域重要性显示代码对于项目的问题域的重要性。通常,领域层中的所有代码都与最终用户的目标直接相关,因此具有很高的领域意义。另一方面,实用程序代码没有这样的联系。

Domain significance shows how significant the code is for the problem domain of your project. Normally, all code in the domain layer has a direct connection to the end users' goals and thus exhibits a high domain significance. On the other hand, utility code doesn’t have such a connection.

复杂代码和具有领域重要性的代码从单元测试中受益最多,因为相应的测试可以很好地防止回归。请注意,领域代码不一定要复杂,复杂代码也不一定要表现出领域重要性才能进行测试。这两个组件相互独立。例如,计算订单价格的方法可以不包含条件语句,因此具有 的圈复杂度1。尽管如此,测试这种方法还是很重要的,因为它代表了业务关键功能。

Complex code and code that has domain significance benefit from unit testing the most because the corresponding tests have great protection against regressions. Note that the domain code doesn’t have to be complex, and complex code doesn’t have to exhibit domain significance to be test-worthy. The two components are independent of each other. For example, a method calculating an order price can contain no conditional statements and thus have the cyclomatic complexity of 1. Still, it’s important to test such a method because it represents business-critical functionality.

第二个维度是类或方法具有的协作者的数量。您可能还记得第 2 章中的内容,协作者是一种依赖关系,它是可变的或进程外的(或两者兼而有之)。具有大量协作者的代码测试成本很高。这是由于可维护性指标,它取决于测试的规模。将协作者带到预期状态然后检查他们的状态或之后与他们的交互需要空间。合作者越多,测试就越大。

The second dimension is the number of collaborators a class or a method has. As you may remember from chapter 2, a collaborator is a dependency that is either mutable or out-of-process (or both). Code with a large number of collaborators is expensive to test. That’s due to the maintainability metric, which depends on the size of the test. It takes space to bring collaborators to an expected condition and then check their state or interactions with them afterward. And the more collaborators there are, the larger the test becomes.

合作者的类型也很重要。当涉及域模型时,进程外协作者是不可取的。由于需要在测试中维护复杂的模拟机械,它们增加了额外的维护成本。您还必须格外谨慎,仅使用模拟来验证跨越应用程序边界的交互,以保持对重构的适当抵抗(有关更多详细信息,请参阅第 5 章)。最好将所有具有进程外依赖关系的通信委托给领域层之外的类。然后域类将仅与进程内依赖项一起使用。

The type of the collaborators also matters. Out-of-process collaborators are a no-go when it comes to the domain model. They add additional maintenance costs due to the necessity to maintain complicated mock machinery in tests. You also have to be extra prudent and only use mocks to verify interactions that cross the application boundary in order to maintain proper resistance to refactoring (refer to chapter 5 for more details). It’s better to delegate all communications with out-of-process dependencies to classes outside the domain layer. The domain classes then will only work with in-process dependencies.

请注意,隐式和显式合作者都计入此数字。被测系统 (SUT) 是否接受协作者作为参数或通过静态方法隐式引用它并不重要,您仍然必须在测试中设置此协作者。相反,不可变的依赖项(值或值对象)不算在内。这样的依赖关系更容易设置和断言。

Notice that both implicit and explicit collaborators count toward this number. It doesn’t matter if the system under test (SUT) accepts a collaborator as an argument or refers to it implicitly via a static method, you still have to set up this collaborator in tests. Conversely, immutable dependencies (values or value objects) don’t count. Such dependencies are much easier to set up and assert against.

代码复杂性、领域重要性和协作者数量的组合给了我们图7.1所示的四种代码类型:

The combination of code complexity, its domain significance, and the number of collaborators give us the four types of code shown in figure 7.1:

  • 域模型和算法(图7.1,左上角)  ——复杂代码通常是域模型的一部分,但并非在所有情况下都是 100%。您可能有一个与问题域没有直接关系的复杂算法。

  • Domain model and algorithms (figure 7.1, top left) — Complex code is often part of the domain model but not in 100% of all cases. You might have a complex algorithm that’s not directly related to the problem domain.

  • 琐碎的代码(图7.1,左下角)  ——C# 中此类代码的示例是无参数构造函数和单行属性:它们几乎没有(如果有的话)合作者,并且表现出很小的复杂性或领域意义。

  • Trivial code (figure 7.1, bottom left) — Examples of such code in C# are parameter-less constructors and one-line properties: they have few (if any) collaborators and exhibit little complexity or domain significance.

  • 控制器(图7.1,右下角)  ——这段代码本身并不执行复杂或关键业务工作,而是协调其他组件(如域类和外部应用程序)的工作。

  • Controllers (figure 7.1, bottom right) — This code doesn’t do complex or business-critical work by itself but coordinates the work of other components like domain classes and external applications.

  • 过于复杂的代码(图7.1,右上角)  ——这样的代码在两个指标上都得分很高:它有很多合作者,而且它也很复杂或很重要。这里的一个例子是胖控制器(不在任何地方委托复杂工作并自己做所有事情的控制器)。

  • Overcomplicated code (figure 7.1, top right) — Such code scores highly on both metrics: it has a lot of collaborators, and it’s also complex or important. An example here are fat controllers (controllers that don’t delegate complex work anywhere and do everything themselves).

图 7.1。四种类型的代码,按代码复杂性和领域重要性(纵轴)以及协作者数量(横轴)分类。

Figure 7.1. The four types of code, categorized by code complexity and domain significance (the vertical axis) and the number of collaborators (the horizontal axis).

CH07 图1 类型代码

单元测试左上象限(域模型和算法)为您的努力提供了最好的回报。由此产生的单元测试非常有价值且便宜。它们很有价值,因为底层代码执行复杂或重要的逻辑,从而增加测试对回归的保护。而且它们很便宜,因为代码的协作者很少(理想情况下,没有),从而降低了测试的维护成本。

Unit testing the top-left quadrant (domain model and algorithms) gives you the best return for your efforts. The resulting unit tests are highly valuable and cheap. They’re valuable because the underlying code carries out complex or important logic, thus increasing tests' protection against regressions. And they’re cheap because the code has few (ideally, none) collaborators, thus decreasing tests' maintenance costs.

根本不应该测试琐碎的代码;此类测试的值接近于零。至于控制器,您应该仅将它们作为一组更小的总体集成测试的一部分进行简要测试(我在第 3 部分中介绍了这个主题)。

Trivial code shouldn’t be tested at all; such tests have a close-to-zero value. As for controllers, you should test them only briefly as part of a much smaller set of the overarching integration tests (I cover this topic in part 3).

问题的代码类型是过于复杂的象限。很难进行单元测试,但在没有测试覆盖的情况下离开太冒险了。此类代码是许多人难以进行单元测试的主要原因之一。整章主要讨论如何绕过这个困境。一般的想法是将过于复杂的代码分成两部分:算法和控制器(图7.2),尽管有时实际实现可能很棘手。

The most problematic type of code is the overcomplicated quadrant. It’s hard to unit test but too risky to leave without test coverage. Such code is one of the main reasons many people struggle with unit testing. This whole chapter is primarily devoted to how you can bypass this dilemma. The general idea is to split overcomplicated code into two parts: algorithms and controllers (figure 7.2), although the actual implementation can be tricky at times.

图 7.2。通过将代码拆分为算法和控制器来重构过于复杂的代码。理想情况下,右上象限中不应有任何代码。

Figure 7.2. Refactor overcomplicated code by splitting it into algorithms and controllers. Ideally, you should have no code in the top-right quadrant.

CH07图2种代码2

 

 

[提示] 提示

代码越重要或越复杂,它应该拥有的协作者就越少。

The more important or complex the code, the fewer collaborators it should have.

摆脱过于复杂的代码并仅对域模型和算法进行单元测试是通往高价值、易于维护的测试套件的途径。使用这种方法,您不会拥有 100% 的测试覆盖率,但您不需要 - 100% 的覆盖率永远不应该是您的目标。您的目标是一个测试套件,其中每个测试都会为项目增加重要价值。重构或摆脱所有其他测试。不要让他们膨胀你的测试套件的大小。

Getting rid of the overcomplicated code and unit testing only the domain model and algorithms is the path to a highly valuable, easily maintainable test suite. With this approach, you won’t have 100% test coverage, but you don’t need to — 100% coverage shouldn’t ever be your goal. Your goal is a test suite where each test adds significant value to the project. Refactor or get rid of all other tests. Don’t allow them to inflate the size of your test suite.

V03 记住,根本不写测试总比写一个糟糕的测试要好。

V03Remember that it’s better to not write a test at all than to write a bad test.

当然,摆脱过于复杂的代码说起来容易做起来难。尽管如此,还是有一些技巧可以帮助您做到这一点。我将首先解释这些技术背后的理论,然后使用接近真实世界的示例来演示它们。

Of course, getting rid of overcomplicated code is easier said than done. Still, there are techniques that can help you do that. I’ll first explain the theory behind those techniques and then demonstrate them using a close-to-real-world example.

7.1.2 使用低级对象模式拆分过于复杂的代码

7.1.2  Using the Humble Object pattern to split overcomplicated code

要拆分过于复杂的代码,您需要使用 Humble Object 设计模式。Gerard Meszaros 在他的书xUnit 测试模式:重构测试代码(Addison-Wesley,2007 年)中介绍了这种模式,作为对抗代码耦合的方法之一,但它有更广泛的应用。您很快就会明白为什么。

To split overcomplicated code, you need to use the Humble Object design pattern. This pattern was introduced by Gerard Meszaros in his book xUnit Test Patterns: Refactoring Test Code (Addison-Wesley, 2007) as one of the ways to battle code coupling, but it has a much broader application. You’ll see why shortly.

我们经常发现代码很难测试,因为它与框架依赖性耦合(见图7.3)。示例包括异步或多线程执行、用户界面、与进程外依赖项的通信等。

We often find that code is hard to test because it’s coupled to a framework dependency (see figure 7.3). Examples include asynchronous or multi-threaded execution, user interfaces, communication with out-of-process dependencies, and so on.

图 7.3。很难测试耦合到困难依赖项的代码。测试也必须处理这种依赖性,这增加了它们的维护成本。

Figure 7.3. It’s hard to test code that couples to a difficult dependency. Tests have to deal with that dependency, too, which increases their maintenance cost.

CH07 图3 卑微1

要测试此代码的逻辑,您需要从中提取可测试的部分。结果,代码变成了可测试部分的薄而简陋的包装器:它将难以测试的依赖项和新提取的组件粘合在一起,但它本身包含很少或没有逻辑,因此不需要测试(图7.4)。

To bring the logic of this code under test, you need to extract a testable part out of it. As a result, the code becomes a thin, humble wrapper around that testable part: it glues the hard-to-test dependency and the newly extracted component together, but itself contains little or no logic and thus doesn’t need to be tested (figure 7.4).

图 7.4。谦虚对象模式从过于复杂的代码中提取逻辑,使代码如此简陋以至于不需要测试。提取的逻辑被移动到另一个类中,与难以测试的依赖项分离。

Figure 7.4. The Humble Object pattern extracts the logic out of the overcomplicated code, making that code so humble that it doesn’t need to be tested. The extracted logic is moved into another class, decoupled from the hard-to-test dependency.

CH07 图4 卑微2

如果这种方法看起来很眼熟,那是因为你已经在本书中看到过它。事实上,六边形和函数式架构都实现了这种模式。您可能还记得前面的章节,六边形架构提倡将业务逻辑和通信与进程外依赖项分开。这是领域和应用服务层分别负责的。

If this approach looks familiar, it’s because you already saw it in this book. In fact, both hexagonal and functional architectures implement this exact pattern. As you may remember from previous chapters, hexagonal architecture advocates for the separation of business logic and communications with out-of-process dependencies. This is what the domain and application services layers are responsible for, respectively.

功能架构走得更远,将业务逻辑与所有协作者的通信分开,而不仅仅是进程外的。这就是使功能架构如此可测试的原因:它的功能核心没有合作者。功能核心中的所有依赖项都是不可变的,这使其非常接近代码类型图(图7.5 )上的垂直轴。

Functional architecture goes even further and separates business logic from communications with all collaborators, not just out-of-process ones. This is what makes functional architecture so testable: its functional core has no collaborators. All dependencies in a functional core are immutable, which brings it very close to the vertical axis on the diagram of types of code (figure 7.5).

图 7.5。功能架构中的功能核心和六边形架构中的领域层位于左上象限:它们的合作者很少,并且表现出高复杂性和领域重要性。功能核心更接近垂直轴,因为它没有合作者。可变外壳(功能架构)和应用服务层(六边形架构)属于控制器的象限。

Figure 7.5. The functional core in a functional architecture and the domain layer in a hexagonal architecture reside in the top-left quadrant: they have few collaborators and exhibit high complexity and domain significance. The functional core is closer to the vertical axis because it has no collaborators. The mutable shell (functional architecture) and the application services layer (hexagonal architecture) belong to the controllers' quadrant.

CH07图5对比

另一种看待谦虚对象模式的方式是将其视为一种遵守单一职责原则的方法,该原则规定每个类都应该只有一个职责。[3]其中一项责任始终是业务逻辑;该模式可用于将该逻辑与几乎所有内容隔离开来。

Another way to view the Humble Object pattern is as a means to adhere to the Single Responsibility principle, which states that each class should have only a single responsibility.[3] One such responsibility is always business logic; the pattern can be applied to segregate that logic from pretty much anything.

在我们的特定情况下,我们对业务逻辑和编排的分离感兴趣。您可以从代码深度代码宽度的角度来思考这两个职责。您的代码可以很深(复杂或重要)或很宽(与许多合作者一起工作),但绝不能两者兼而有之(图7.6)。

In our particular situation, we are interested in the separation of business logic and orchestration. You can think of these two responsibilities in terms of code depth versus code width. Your code can be either deep (complex or important) or wide (work with many collaborators), but never both (figure 7.6).

图 7.6。当您考虑业务逻辑和编排职责之间的分离时,代码深度与代码宽度是一个有用的比喻。控制器协调许多依赖项(在图中表示为箭头),但它们本身并不复杂(复杂性表示为块高度)。域类与此相反。

Figure 7.6. Code depth versus code width is a useful metaphor to apply when you think of the separation between the business logic and orchestration responsibilities. Controllers orchestrate many dependencies (represented as arrows in the figure) but aren’t complex on their own (complexity is represented as block height). Domain classes are the opposite of that.

CH07 图6深度宽度

我怎么强调这种分离的重要性都不为过。事实上,许多众所周知的原则和模式都可以描述为谦逊对象模式的一种形式:它们专门设计用于将复杂代码与执行编排的代码隔离开来。

I can’t stress enough how important this separation is. In fact, many well-known principles and patterns can be described as a form of the Humble Object pattern: they are designed specifically to segregate complex code from the code that does orchestration.

您已经看到了这种模式与六边形和功能架构之间的关系。其他示例包括模型-视图-展示器 (MVP) 和模型-视图-控制器 (MVC) 模式。这两种模式帮助您解耦业务逻辑(模型部分)、UI 关注点(视图)以及它们之间的协调(PresenterController)。Presenter 和 Controller 组件是不起眼的对象:它们将视图和模型粘合在一起。

You already saw the relationship between this pattern and hexagonal and functional architectures. Other examples include the Model-View-Presenter (MVP) and the Model-View-Controller (MVC) patterns. These two patterns help you decouple business logic (the Model part), UI concerns (the View), and the coordination between them (Presenter or Controller). The Presenter and Controller components are humble objects: they glue the view and the model together.

另一个例子是领域驱动设计中的聚合模式。[4]它的目标之一是通过将类分组为集群或聚合来减少类之间的连接。这些类在这些集群内部高度连接,但集群本身是松散耦合的。这种结构减少了代码库中的通信总数。减少的连接性反过来又提高了可测试性。

Another example is the Aggregate pattern from Domain-Driven Design.[4] One of its goals is to reduce connectivity between classes by grouping them into clusters, or aggregates. The classes are highly connected inside those clusters, but the clusters themselves are loosely coupled. Such a structure decreases the total number of communications in the code base. The reduced connectivity, in turn, improves testability.

请注意,改进的可测试性并不是保持业务逻辑和编排之间分离的唯一原因。这种分离还有助于解决代码复杂性,这对于项目增长也至关重要,尤其是从长远来看。我个人总是觉得可测试设计不仅可测试而且易于维护是多么令人着迷。

Note that improved testability is not the only reason to maintain the separation between business logic and orchestration. Such a separation also helps tackle code complexity, which is crucial for project growth, too, especially in the long run. I personally always find it fascinating how a testable design is not only testable but also easy to maintain.



[3]请参阅Robert C. Martin 和 Micah Martin 撰写的《 C# 中的敏捷原则、模式和实践》 (Prentice Hall,2006 年)。

[3] See Agile Principles, Patterns, and Practices in C# by Robert C. Martin and Micah Martin (Prentice Hall, 2006).

[4]请参阅Eric Evans 的领域驱动设计:解决软件核心的复杂性问题(Addison-Wesley,2003 年)。

[4] See Domain-Driven Design: Tackling Complexity in the Heart of Software by Eric Evans (Addison-Wesley, 2003).

7.2 重构有价值的单元测试

7.2  Refactoring toward valuable unit tests

在本节中,我将展示一个将过于复杂的代码拆分为算法和控制器的综合示例。您在上一章中看到了一个类似的示例,其中我们讨论了基于输出的测试和功能架构。这一次,我将在 Humble Object 模式的帮助下将这种方法推广到所有企业级应用程序。我不仅会在本章中使用这个项目,还会在第 2 部分和第 3 部分的后续章节中使用它。

In this section, I’ll show a comprehensive example of splitting overcomplicated code into algorithms and controllers. You saw a similar example in the previous chapter, where we talked about output-based testing and functional architecture. This time, I’ll generalize this approach to all enterprise-level applications, with the help of the Humble Object pattern. I’ll use this project not only in this chapter but in the subsequent chapters of parts 2 and 3.

7.2.1 引入客户管理系统

7.2.1  Introducing a customer management system

示例项目是处理用户注册的客户管理系统 (CRM)。所有用户都存储在数据库中。该系统目前仅支持一种用例:更改用户的电子邮件。该操作涉及三个业务规则:

The sample project is a customer management system (CRM) that handles user registrations. All users are stored in a database. The system currently supports only one use case: changing a user’s email. There are three business rules involved in this operation:

  • 如果用户的电子邮件属于公司域,则该用户被标记为员工。否则,他们将被视为客户。

  • If the user’s email belongs to the company’s domain, that user is marked as an employee. Otherwise, they are treated as a customer.

  • 系统必须跟踪公司的员工人数。如果用户的类型从雇员变为客户,反之亦然,则此编号也必须更改。

  • The system must track the number of employees in the company. If the user’s type changes from employee to customer, or vice versa, this number must change, too.

  • 当电子邮件发生变化时,系统必须通过向消息总线发送消息来通知外部系统。

  • When the email changes, the system must notify external systems by sending a message to a message bus.

下面的清单显示了 CRM 系统的初始实现。

The following listing shows the initial implementation of the CRM system.

清单 7.1。CRM系统初步实施

Listing 7.1. Initial implementation of the CRM system

公共类用户
{
    public int UserId { 得到; 私有集;}
    公共字符串电子邮件 { 得到; 私有集;}
    公共 UserType 类型 { 得到; 私有集;}

    public void ChangeEmail(int userId, string newEmail)
    {
        object[] data = Database.GetUserById(userId);   
        UserId = 用户Id;
        电子邮件=(字符串)数据[1];
        类型 = (用户类型)数据[2];

        如果(电子邮件 == 新电子邮件)
            返回;

        对象[] companyData = Database.GetCompany();   
        字符串 companyDomainName = (string)companyData[0];
        int numberOfEmployees = (int)companyData[1];

        string emailDomain = newEmail.Split('@')[1];
        bool isEmailCorporate = emailDomain == companyDomainName;
        用户类型 newType = isEmailCorporate    
            ?用户类型.员工                
            : 用户类型.客户;              

        如果(类型!=新类型)
        {
            int delta = newType == UserType.Employee ?1 : -1;
            int newNumber = numberOfEmployees + 增量;
            数据库.SaveCompany(newNumber);   
        }

        邮箱 = newEmail;
        类型 = 新类型;

        数据库.SaveUser(这个);    
        MessageBus.SendEmailChangedMessage(UserId, newEmail);   
    }
}

公共枚举用户类型
{
    客户= 1,
    员工 = 2
}
public class User
{
    public int UserId { get; private set; }
    public string Email { get; private set; }
    public UserType Type { get; private set; }

    public void ChangeEmail(int userId, string newEmail)
    {
        object[] data = Database.GetUserById(userId);   
        UserId = userId;
        Email = (string)data[1];
        Type = (UserType)data[2];

        if (Email == newEmail)
            return;

        object[] companyData = Database.GetCompany();   
        string companyDomainName = (string)companyData[0];
        int numberOfEmployees = (int)companyData[1];

        string emailDomain = newEmail.Split('@')[1];
        bool isEmailCorporate = emailDomain == companyDomainName;
        UserType newType = isEmailCorporate   
            ? UserType.Employee               
            : UserType.Customer;              

        if (Type != newType)
        {
            int delta = newType == UserType.Employee ? 1 : -1;
            int newNumber = numberOfEmployees + delta;
            Database.SaveCompany(newNumber);   
        }

        Email = newEmail;
        Type = newType;

        Database.SaveUser(this);   
        MessageBus.SendEmailChangedMessage(UserId, newEmail);   
    }
}

public enum UserType
{
    Customer = 1,
    Employee = 2
}

从数据库中检索用户的当前电子邮件和类型

Retrieves the user’s current email and type from the database

从数据库中检索组织的域名和员工人数

Retrieves the organization’s domain name and the number of employees from the database

根据新邮件的域名设置用户类型

Sets the user type depending on the new email’s domain name

如果需要,更新组织中的员工数量

Updates the number of employees in the organization, if needed

将用户保存在数据库中

Persists the user in the database

向消息总线发送通知

Sends a notification to the message bus

该类User更改用户电子邮件。请注意,为简洁起见,我省略了简单的验证,例如检查电子邮件的正确性和用户是否存在于数据库中。我们从代码类型图的角度来分析这个实现。

The User class changes a user email. Note that, for brevity, I omitted simple validations such as checks for email correctness and user existence in the database. Let’s analyze this implementation from the perspective of the diagram of types of code.

代码的复杂度不是太高。该ChangeEmail方法仅包含几个明确的决策点:是将用户标识为员工还是客户,以及如何更新公司的员工人数。尽管很简单,但这些决定很重要:它们是应用程序的核心业务逻辑。因此,该类在复杂性和领域重要性维度上得分很高。

The code’s complexity is not too high. The ChangeEmail method contains only a couple of explicit decision-making points: whether to identify the user as an employee or a customer, and how to update the company’s number of employees. Despite being simple, these decisions are important: they are the application’s core business logic. Hence, the class scores highly on the complexity and domain significance dimension.

另一方面,该类User有四个依赖项,其中两个是显式的,另外两个是隐式的。显式依赖项是userIdnewEmail参数。不过,这些都是值,因此不计入班级的合作者人数。隐含的是DatabaseMessageBus。这两个是进程外的合作者。正如我之前提到的,进程外协作者不适合具有高领域重要性的代码。因此,该User班级在合作者维度上得分很高,这使该班级属于过于复杂的类别(图7.7)。

On the other hand, the User class has four dependencies, two of which are explicit and the other two of which are implicit. The explicit dependencies are the userId and newEmail arguments. These are values, though, and thus don’t count toward the class’s number of collaborators. The implicit ones are Database and MessageBus. These two are out-of-process collaborators. As I mentioned earlier, out-of-process collaborators are a no-go for code with high domain significance. Hence, the User class scores highly on the collaborators dimension, which puts this class into the overcomplicated category (figure 7.7).

图 7.7。该类的初始实现User在两个维度上都得分很高,因此属于过于复杂的代码类别。

Figure 7.7. The initial implementation of the User class scores highly on both dimensions and thus falls into the category of overcomplicated code.

CH07图7版本1

这种方法——当一个域类检索并将其自身持久化到数据库时——称为 Active Record 模式。它在简单或短期项目中运行良好,但通常无法随着代码库的增长而扩展。原因恰恰是这两种职责之间缺乏分离:业务逻辑和与进程外依赖项的通信。

This approach — when a domain class retrieves and persists itself to the database — is called the Active Record pattern. It works fine in simple or short-lived projects but often fails to scale as the code base grows. The reason is precisely this lack of separation between these two responsibilities: business logic and communication with out-of-process dependencies.

7.2.2 练习 1:使隐式依赖显式化

7.2.2  Take 1: Making implicit dependencies explicit

提高可测试性的常用方法是使隐式依赖显式化:即为Database和引入接口MessageBus,将这些接口注入User,然后在测试中模拟它们。这种方法确实有帮助,而这正是我们在上一章介绍审计系统模拟实现时所做的。然而,这还不够。

The usual approach to improve testability is to make implicit dependencies explicit: that is, introduce interfaces for Database and MessageBus, inject those interfaces into User, and then mock them in tests. This approach does help, and that’s exactly what we did in the previous chapter when we introduced the implementation with mocks for the audit system. However, it’s not enough.

从代码类型图的角度来看,域模型是直接引用进程外依赖关系还是通过接口引用并不重要。这样的依赖仍然是进程外的;它们是尚未在内存中的数据的代理。您仍然需要维护复杂的模拟机器以测试此类类,这会增加测试的维护成本。此外,对数据库依赖使用模拟会导致测试脆弱性(我们将在下一章讨论)。

From the perspective of the diagram of types of code, it doesn’t matter if the domain model refers to out-of-process dependencies directly or via an interface. Such dependencies are still out-of-process; they are proxies to data that is not yet in memory. You still need to maintain complicated mock machinery in order to test such classes, which increases the tests' maintenance costs. Moreover, using mocks for the database dependency would lead to test fragility (we’ll discuss this in the next chapter).

总的来说,对于域模型来说,完全不直接或间接(通过接口)依赖于进程外的协作者要干净得多。这也是六边形架构所提倡的——领域模型不应该负责与外部系统的通信。

Overall, it’s much cleaner for the domain model not to depend on out-of-process collaborators at all, directly or indirectly (via an interface). That’s what the hexagonal architecture advocates as well — the domain model shouldn’t be responsible for communications with external systems.

7.2.3 Take 2:引入应用服务层

7.2.3  Take 2: Introducing an application services layer

为了克服域模型直接与外部系统通信的问题,我们需要将这个责任转移到另一个类,一个不起眼的控制器(应用服务,在六边形架构分类中)。作为一般规则,域类应该只依赖于进程内依赖项,例如其他域类或普通值。这是该应用程序服务的第一个版本的样子。

To overcome the problem of the domain model directly communicating with external systems, we need to shift this responsibility to another class, a humble controller (an application service, in the hexagonal architecture taxonomy). As a general rule, domain classes should only depend on in-process dependencies, such as other domain classes, or plain values. Here’s what the first version of that application service looks like.

清单 7.2。应用程序服务,版本 1

Listing 7.2. Application service, version 1

公共类用户控制器
{
    私有只读数据库_database = new Database();
    private readonly MessageBus _messageBus = new MessageBus();

    public void ChangeEmail(int userId, string newEmail)
    {
        object[] data = _database.GetUserById(userId);
        字符串电子邮件=(字符串)数据[1];
        用户类型类型 = (用户类型)数据[2];
        var user = new User(userId, email, type);

        对象 [] 公司数据 = _database.GetCompany();
        字符串 companyDomainName = (string)companyData[0];
        int numberOfEmployees = (int)companyData[1];

        int newNumberOfEmployees = user.ChangeEmail(
            newEmail, companyDomainName, numberOfEmployees);

        _database.SaveCompany(newNumberOfEmployees);
        _database.SaveUser(用户);
        _messageBus.SendEmailChangedMessage(userId, newEmail);
    }
}
public class UserController
{
    private readonly Database _database = new Database();
    private readonly MessageBus _messageBus = new MessageBus();

    public void ChangeEmail(int userId, string newEmail)
    {
        object[] data = _database.GetUserById(userId);
        string email = (string)data[1];
        UserType type = (UserType)data[2];
        var user = new User(userId, email, type);

        object[] companyData = _database.GetCompany();
        string companyDomainName = (string)companyData[0];
        int numberOfEmployees = (int)companyData[1];

        int newNumberOfEmployees = user.ChangeEmail(
            newEmail, companyDomainName, numberOfEmployees);

        _database.SaveCompany(newNumberOfEmployees);
        _database.SaveUser(user);
        _messageBus.SendEmailChangedMessage(userId, newEmail);
    }
}

这是一个很好的第一次尝试;应用程序服务帮助从类中卸载进程外依赖项的工作User。但是这个实现有一些问题:

This is a good first try; the application service helped offload the work with out-of-process dependencies from the User class. But there are some issues with this implementation:

  • 进程外依赖项 (DatabaseMessageBus) 是直接实例化的,而不是注入的。对于我们将为该课程编写的集成测试,这将是一个问题。

  • The out-of-process dependencies (Database and MessageBus) are instantiated directly, not injected. That’s going to be a problem for the integration tests we’ll be writing for this class.

  • 控制器User根据从数据库接收到的原始数据重建一个实例。这是复杂的逻辑,因此不应该属于应用程序服务,它的唯一作用是编排,而不是任何复杂性或领域意义的逻辑。

  • The controller reconstructs a User instance from the raw data it receives from the database. This is complex logic and thus shouldn’t belong to the application service, whose sole role is orchestration, not logic of any complexity or domain significance.

  • 公司的数据也是如此,但这并不是唯一与数据相关的问题。另一个问题是User现在返回更新后的员工数量,这看起来不正确。公司员工人数与具体用户无关。这个责任应该属于别处。

  • The same is true for the company’s data, but it’s not the only data-related problem. The other problem is that User now returns an updated number of employees, which doesn’t look right. The number of company employees has nothing to do with a specific user. This responsibility should belong elsewhere.

  • 控制器持久化修改后的数据并无条件地向消息总线发送通知,无论新邮件是否与之前的邮件不同。

  • The controller persists modified data and sends notifications to the message bus unconditionally, regardless of whether the new email is different than the previous one.

该类User变得非常容易测试,因为它不再需要与进程外依赖项进行通信。事实上,它没有任何合作者——进程外与否。User这是的方法的新版本ChangeEmail

The User class has become quite easy to test because it no longer has to communicate with out-of-process dependencies. In fact, it has no collaborators whatsoever — out-of-process or not. Here’s the new version of User's ChangeEmail method:

public int ChangeEmail(string newEmail,
    string companyDomainName, int numberOfEmployees)
{
    如果(电子邮件 == 新电子邮件)
        返回员工人数;

    string emailDomain = newEmail.Split('@')[1];
    bool isEmailCorporate = emailDomain == companyDomainName;
    用户类型 newType = isEmailCorporate
        ?用户类型.员工
        : 用户类型.客户;

    如果(类型!=新类型)
    {
        int delta = newType == UserType.Employee ?1 : -1;
        int newNumber = numberOfEmployees + 增量;
        雇员人数 = 新人数;
    }

    邮箱 = newEmail;
    类型 = 新类型;

    返回员工人数;
}
public int ChangeEmail(string newEmail,
    string companyDomainName, int numberOfEmployees)
{
    if (Email == newEmail)
        return numberOfEmployees;

    string emailDomain = newEmail.Split('@')[1];
    bool isEmailCorporate = emailDomain == companyDomainName;
    UserType newType = isEmailCorporate
        ? UserType.Employee
        : UserType.Customer;

    if (Type != newType)
    {
        int delta = newType == UserType.Employee ? 1 : -1;
        int newNumber = numberOfEmployees + delta;
        numberOfEmployees = newNumber;
    }

    Email = newEmail;
    Type = newType;

    return numberOfEmployees;
}

7.8显示了我们图中的位置User和当前位置。已经移动到领域模型象限,靠近垂直轴,因为它不再需要与协作者打交道。问题更大。虽然我把它放到了控制器象限,但它几乎越过了边界,变成了过于复杂的代码,因为它包含的逻辑非常复杂。UserControllerUserUserController

Figure 7.8 shows where User and UserController currently stand in our diagram. User has moved to the domain model quadrant, close to the vertical axis, because it no longer has to deal with collaborators. UserController is more problematic. Although I’ve put it into the controllers quadrant, it almost crosses the boundary into overcomplicated code because it contains logic that is quite complex.

图 7.8。版本 2 放在User域模型象限中,靠近垂直轴。UserController几乎跨越了过度复杂象限的边界,因为它包含了复杂的逻辑。

Figure 7.8. Version 2 puts User in the domain model quadrant, close to the vertical axis. UserController almost crosses the boundary with the overcomplicated quadrant because it contains complex logic.

CH07图8版2

7.2.4 Take 3:消除应用服务的复杂性

7.2.4  Take 3: Removing complexity from the application service

为了UserController稳固地进入控制器象限,我们需要从中提取重构逻辑。如果您使用对象关系映射 (ORM) 库将数据库映射到域模型,那将是将重构逻辑归于其中的好地方。每个 ORM 库都有一个专用位置,您可以在其中指定应如何将数据库表映射到域类,例如这些域类、XML 文件或具有流畅映射的文件之上的属性。

To put UserController firmly into the controllers quadrant, we need to extract the reconstruction logic from it. If you use an object-relational mapping (ORM) library to map the database into the domain model, that would be a good place to which to attribute the reconstruction logic. Each ORM library has a dedicated place where you can specify how your database tables should be mapped to domain classes, such as attributes on top of those domain classes, XML files, or files with fluent mappings.

如果您不想或不能使用 ORM,请在域模型中创建一个工厂,该工厂将使用原始数据库数据实例化域类。这个工厂可以是一个单独的类,或者,对于更简单的情况,现有域类中的静态方法。我们的示例应用程序中的重构逻辑并不太复杂,但最好将这些东西分开,所以我将其放在一个单独的UserFactory类中。

If you don’t want to or can’t use an ORM, create a factory in the domain model that will instantiate the domain classes using raw database data. This factory can be a separate class or, for simpler cases, a static method in the existing domain classes. The reconstruction logic in our sample application is not too complicated, but it’s good to keep such things separated, so I’m putting it in a separate UserFactory class.

清单 7.3。用户工厂

Listing 7.3. User factory

公共类 UserFactory
{
    公共静态用户创建(对象[]数据)
    {
        Precondition.Requires(data.Length >= 3);

        int id = (int)data[0];
        字符串电子邮件=(字符串)数据[1];
        用户类型类型 = (用户类型)数据[2];

        返回新用户(ID、电子邮件、类型);
    }
}
public class UserFactory
{
    public static User Create(object[] data)
    {
        Precondition.Requires(data.Length >= 3);

        int id = (int)data[0];
        string email = (string)data[1];
        UserType type = (UserType)data[2];

        return new User(id, email, type);
    }
}

该代码现在与所有协作者完全隔离,因此易于测试。请注意,我在此方法中设置了一个保护措施:要求数据数组中至少包含三个元素。Precondition是一个简单的自定义类,如果 Boolean 参数为 false 则抛出异常。这个类的原因是更简洁的代码和条件反转:肯定语句比否定语句更具可读性。在我们的示例中,data.Length >= 3需求读起来比

This code is now fully isolated from all collaborators and therefore easily testable. Notice that I’ve put a safeguard in this method: a requirement to have at least three elements in the data array. Precondition is a simple custom class that throws an exception if the Boolean argument is false. The reason for this class is the more succinct code and the condition inversion: affirmative statements are more readable than negative ones. In our example, the data.Length >= 3 requirement reads better than

如果(数据。长度 < 3)
    抛出新的异常();
if (data.Length < 3)
    throw new Exception();

请注意,虽然此重构逻辑有些复杂,但它没有领域意义:它与客户端更改用户电子邮件的目标没有直接关系。这是我在前几章中一直提到的实用程序代码的一个例子。

Note that while this reconstruction logic is somewhat complex, it doesn’t have domain significance: it isn’t directly related to the client’s goal of changing the user email. It’s an example of the utility code I kept referring to in previous chapters.

重构逻辑有多复杂?

How is the reconstruction logic complex?

考虑到方法中只有一个分支点,重构逻辑有多复杂UserFactory.Create()?正如我在第 1 章中提到的,代码使用的底层库中可能有很多隐藏的分支点,因此很可能出现问题。该方法正是这种情况UserFactory.Create()

How is the reconstruction logic complex, given that there’s only a single branching point in the UserFactory.Create() method? As I mentioned in chapter 1, there could be a lot of hidden branching points in the underlying libraries used by the code and thus a lot of potential for something to go wrong. This is exactly the case for the UserFactory.Create() method.

通过索引 ( ) 引用数组元素data[0]需要由 .NET Framework 做出关于访问什么数据元素的内部决定。object从到int或 的转换也是如此string。在内部,.NET Framework 决定是抛出强制转换异常还是允许继续进行转换。尽管其中缺少决策点,但所有这些隐藏的分支都使重构逻辑具有测试价值。

Referring to an array element by index (data[0]) entails an internal decision made by the .NET Framework as to what data element to access. The same is true for the conversion from object to int or string. Internally, the .NET Framework decides whether to throw a cast exception or allow the conversion to proceed. All these hidden branches make the reconstruction logic test-worthy, despite the lack of decision points in it.

7.2.5 Take 3:引入一个新的 Company 类

7.2.5  Take 3: Introducing a new Company class

再次查看控制器中的这段代码:

Look at this code in the controller once again:

对象 [] 公司数据 = _database.GetCompany();
字符串 companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];

int newNumberOfEmployees = user.ChangeEmail(
    newEmail, companyDomainName, numberOfEmployees);
object[] companyData = _database.GetCompany();
string companyDomainName = (string)companyData[0];
int numberOfEmployees = (int)companyData[1];

int newNumberOfEmployees = user.ChangeEmail(
    newEmail, companyDomainName, numberOfEmployees);

返回更新数量的员工的尴尬User是责任错位的标志,这本身就是抽象缺失的标志。为了解决这个问题,我们需要引入另一个域类 ,Company它将与公司相关的逻辑和数据捆绑在一起。

The awkwardness of returning an updated number of employees from User is a sign of a misplaced responsibility, which itself is a sign of a missing abstraction. To fix this, we need to introduce another domain class, Company, that bundles the company-related logic and data together.

清单 7.4。领域层中的新类

Listing 7.4. The new class in the domain layer

公共类公司
{
    公共字符串域名 { 得到; 私有集;}
    public int NumberOfEmployees { 得到; 私有集;}

    公共无效 ChangeNumberOfEmployees(int 增量)
    {
        Precondition.Requires(NumberOfEmployees + delta >= 0);

        NumberOfEmployees += 增量;
    }

    公共布尔 IsEmailCorporate(字符串电子邮件)
    {
        字符串 emailDomain = email.Split('@')[1];
        返回电子邮件域 == 域名;
    }
}
public class Company
{
    public string DomainName { get; private set; }
    public int NumberOfEmployees { get; private set; }

    public void ChangeNumberOfEmployees(int delta)
    {
        Precondition.Requires(NumberOfEmployees + delta >= 0);

        NumberOfEmployees += delta;
    }

    public bool IsEmailCorporate(string email)
    {
        string emailDomain = email.Split('@')[1];
        return emailDomain == DomainName;
    }
}

这个类中有两个方法:ChangeNumberOfEmployees()IsEmailCorporate()。这些方法有助于遵守我在第 5 章中提到的告诉不问原则。该原则提倡将数据和对该数据的操作捆绑在一起。实例User告诉公司更改其员工数量或确定特定电子邮件是否为公司电子邮件;它不会要求原始数据并自行完成所有操作。

There are two methods in this class: ChangeNumberOfEmployees() and IsEmailCorporate(). These methods help adhere to the tell-don’t-ask principle I mentioned in chapter 5. This principle advocates for bundling together data and operations on that data. A User instance will tell the company to change its number of employees or figure out whether a particular email is corporate; it won’t ask for the raw data and do everything on its own.

还有一个新CompanyFactory类,负责Company对象的重构,类似于UserFactory. 这就是控制器现在的样子。

There’s also a new CompanyFactory class, which is responsible for the reconstruction of Company objects, similar to UserFactory. This is how the controller now looks.

清单 7.5。重构后的控制器

Listing 7.5. Controller after refactoring

公共类用户控制器
{
    私有只读数据库_database = new Database();
    private readonly MessageBus _messageBus = new MessageBus();

    public void ChangeEmail(int userId, string newEmail)
    {
        object[] userData = _database.GetUserById(userId);
        用户 user = UserFactory.Create(userData);

        对象 [] 公司数据 = _database.GetCompany();
        公司公司 = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _database.SaveCompany(公司);
        _database.SaveUser(用户);
        _messageBus.SendEmailChangedMessage(userId, newEmail);
    }
}
public class UserController
{
    private readonly Database _database = new Database();
    private readonly MessageBus _messageBus = new MessageBus();

    public void ChangeEmail(int userId, string newEmail)
    {
        object[] userData = _database.GetUserById(userId);
        User user = UserFactory.Create(userData);

        object[] companyData = _database.GetCompany();
        Company company = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _database.SaveCompany(company);
        _database.SaveUser(user);
        _messageBus.SendEmailChangedMessage(userId, newEmail);
    }
}

这是User课程。

And here’s the User class.

清单 7.6。 User重构后

Listing 7.6. User after refactoring

公共类用户
{
    public int UserId { 得到; 私有集;}
    公共字符串电子邮件 { 得到; 私有集;}
    公共 UserType 类型 { 得到; 私有集;}

    public void ChangeEmail(string newEmail, Company 公司)
    {
        如果(电子邮件 == 新电子邮件)
            返回;

        用户类型 newType = company.IsEmailCorporate(newEmail)
            ?用户类型.员工
            : 用户类型.客户;

        如果(类型!=新类型)
        {
            int delta = newType == UserType.Employee ?1 : -1;
            公司.ChangeNumberOfEmployees(增量);
        }

        邮箱 = newEmail;
        类型 = 新类型;
    }
}
public class User
{
    public int UserId { get; private set; }
    public string Email { get; private set; }
    public UserType Type { get; private set; }

    public void ChangeEmail(string newEmail, Company company)
    {
        if (Email == newEmail)
            return;

        UserType newType = company.IsEmailCorporate(newEmail)
            ? UserType.Employee
            : UserType.Customer;

        if (Type != newType)
        {
            int delta = newType == UserType.Employee ? 1 : -1;
            company.ChangeNumberOfEmployees(delta);
        }

        Email = newEmail;
        Type = newType;
    }
}

请注意删除错位的责任是如何变得User更加清晰的。它不对公司数据进行操作,而是接受一个Company实例并将两项重要工作委托给该实例:确定电子邮件是否为公司电子邮件以及更改公司员工人数。

Notice how the removal of the misplaced responsibility made User much cleaner. Instead of operating on company data, it accepts a Company instance and delegates two important pieces of work to that instance: determining whether an email is corporate and changing the number of employees in the company.

7.9显示了每个类在图中的位置。工厂和两个领域类都位于领域模型和算法象限中。User已移至右侧,因为它现在有一个合作者 ,Company而之前没有。这降低了User可测试性,但影响不大。

Figure 7.9 shows where each class stands in the diagram. The factories and both domain classes reside in the domain model and algorithms quadrant. User has moved to the right because it now has one collaborator, Company, whereas previously it had none. That has made User less testable, but not much.

图 7.9。 User已向右移动,因为它现在有Company合作者。UserController坚定地站在控制者象限;它的所有复杂性都转移到了工厂。

Figure 7.9. User has shifted to the right because it now has the Company collaborator. UserController firmly stands in the controllers quadrant; all its complexity has moved to the factories.

CH07图9第3版

UserController现在牢牢地站在控制器象限中,因为它的所有复杂性都已转移到工厂。这个类唯一负责的是将所有协作方粘合在一起。

UserController now firmly stands in the controllers quadrant because all of its complexity has moved to the factories. The only thing this class is responsible for is gluing together all the collaborating parties.

请注意此实现与上一章的功能架构之间的相似之处。审计系统中的功能核心和此 CRM 中的领域层(类UserCompany类)都不与进程外依赖项进行通信。在这两种实现中,应用服务层负责此类通信:它从文件系统或数据库获取原始数据,将数据传递给无状态算法或领域模型,然后将结果持久化回数据存储。

Note the similarities between this implementation and the functional architecture from the previous chapter. Neither the functional core in the audit system nor the domain layer in this CRM (the User and Company classes) communicates with out-of-process dependencies. In both implementations, the application services layer is responsible for such communication: it gets the raw data from the filesystem or from the database, passes that data to stateless algorithms or the domain model, and then persists the results back to the data storage.

两种实现的区别在于它们对副作用的处理。功能核心不会产生任何副作用。CRM 的领域模型确实如此,但所有这些副作用都以更改的用户电子邮件和员工数量的形式保留在领域模型中。当控制器在数据库中持久化User和对象时,副作用只会跨越域模型的边界。Company

The difference between the two implementations is in their treatment of side effects. The functional core doesn’t incur any side effects whatsoever. The CRM’s domain model does, but all those side effects remain inside the domain model in the form of the changed user email and the number of employees. The side effects only cross the domain model’s boundary when the controller persists the User and Company objects in the database.

直到最后一刻,所有副作用都包含在内存中,这一事实大大提高了可测试性。您的测试不需要检查进程外依赖性,也不需要求助于基于通信的测试。所有验证都可以使用内存中对象的基于输出和基于状态的测试来完成。

The fact that all side effects are contained in memory until the very last moment improves testability a lot. Your tests don’t need to examine out-of-process dependencies, nor do they need to resort to communication-based testing. All the verification can be done using output-based and state-based testing of objects in memory.

7.3 最优单元测试覆盖率分析

7.3  Analysis of optimal unit test coverage

现在我们已经借助 Humble Object 模式完成了重构,让我们分析一下项目的哪些部分属于哪个代码类别,以及应该如何测试这些部分。表7.1显示了示例项目中的所有代码,这些代码在代码类型图中按位置分组。

Now that we’ve completed the refactoring with the help of the Humble Object pattern, let’s analyze which parts of the project fall into which code category and how those parts should be tested. Table 7.1 shows all the code from the sample project grouped by position in the diagram of types of code.

表 7.1。使用 Humble Object 模式重构后示例项目中的代码类型

Table 7.1. Types of code in the sample project after refactoring using the Humble Object pattern

  合作者少 许多合作者

高复杂性或领域重要性

High complexity or domain significance

ChangeEmail(newEmail, company)User,

ChangeEmail(newEmail, company) in User,

ChangeNumberOfEmployees(delta)在, IsEmailCorporate(email)_Company

ChangeNumberOfEmployees(delta) and IsEmailCorporate(email) in Company,

Create(data)UserFactoryCompanyFactory

Create(data) in UserFactory and CompanyFactory

 

低复杂度和领域重要性

Low complexity and domain significance

User和中的构造函数Company

Constructors in User and Company

ChangeEmail(userId, newEmail)UserController

ChangeEmail(userId, newEmail) in UserController

随着业务逻辑和编排的完全分离,很容易决定对代码库的哪些部分进行单元测试。

With the full separation of business logic and orchestration at hand, it’s easy to decide which parts of the code base to unit test.

7.3.1 测试领域层和实用代码

7.3.1  Testing the domain layer and utility code

表7.1左上象限中的测试方法在成本效益方面提供了最佳结果。代码的高复杂性或领域重要性保证了对回归的强大保护,而很少的协作者确保了最低的维护成本。User这是如何测试的示例:

Testing methods in the top-left quadrant in table 7.1 provides the best results in cost-benefit terms. The code’s high complexity or domain significance guarantees great protection against regressions, while having few collaborators ensures the lowest maintenance costs. This is an example of how User could be tested:

[事实]
公共无效 Changing_email_from_non_corporate_to_corporate()
{
    var company = new Company("mycorp.com", 1);
    var sut = new User(1, "user@gmail.com", UserType.Customer);

    sut.ChangeEmail("new@mycorp.com", 公司);

    Assert.Equal(2, company.NumberOfEmployees);
    Assert.Equal("new@mycorp.com", sut.Email);
    Assert.Equal(UserType.Employee, sut.Type);
}
[Fact]
public void Changing_email_from_non_corporate_to_corporate()
{
    var company = new Company("mycorp.com", 1);
    var sut = new User(1, "user@gmail.com", UserType.Customer);

    sut.ChangeEmail("new@mycorp.com", company);

    Assert.Equal(2, company.NumberOfEmployees);
    Assert.Equal("new@mycorp.com", sut.Email);
    Assert.Equal(UserType.Employee, sut.Type);
}

要实现全面覆盖,您需要另外三个这样的测试:

To achieve full coverage, you’d need another three such tests:

public void Changing_email_from_corporate_to_non_corporate()
public void Changing_email_without_changing_user_type()
public void Changing_email_to_the_same_one()
public void Changing_email_from_corporate_to_non_corporate()
public void Changing_email_without_changing_user_type()
public void Changing_email_to_the_same_one()

其他三个类的测试会更短,您可以使用参数化测试将几个测试用例组合在一起:

Tests for the other three classes would be even shorter, and you could use parameterized tests to group several test cases together:

[InlineData("mycorp.com", "email@mycorp.com", true)]
[InlineData("mycorp.com", "email@gmail.com", false)]
[理论]
public void Differentiates_a_corporate_email_from_non_corporate(
    字符串域,字符串电子邮件,bool expectedResult)
{
    var sut = 新公司(域名,0);

    bool isEmailCorporate = sut.IsEmailCorporate(电子邮件);

    Assert.Equal(expectedResult, isEmailCorporate);
}
[InlineData("mycorp.com", "email@mycorp.com", true)]
[InlineData("mycorp.com", "email@gmail.com", false)]
[Theory]
public void Differentiates_a_corporate_email_from_non_corporate(
    string domain, string email, bool expectedResult)
{
    var sut = new Company(domain, 0);

    bool isEmailCorporate = sut.IsEmailCorporate(email);

    Assert.Equal(expectedResult, isEmailCorporate);
}

7.3.2 从其他三个象限测试代码

7.3.2  Testing the code from the other three quadrants

复杂度低且协作者少的代码(表7.1中的左下象限)由User和中的构造函数表示Company,例如

Code with low complexity and few collaborators (bottom-left quadrant in table 7.1) is represented by the constructors in User and Company, such as

公共用户(int userId,字符串电子邮件,UserType 类型)
{
    UserId = 用户Id;
    电子邮件=电子邮件;
    类型=类型;
}
public User(int userId, string email, UserType type)
{
    UserId = userId;
    Email = email;
    Type = type;
}

这些构造函数微不足道,不值得付出努力。由此产生的测试不会提供足够好的保护来防止回归。

These constructors are trivial and aren’t worth the effort. The resulting tests wouldn’t provide great enough protection against regressions.

重构已经消除了所有具有高复杂性和大量协作者的代码(右上象限,表7.1),因此我们也没有什么可测试的。至于控制器象限(右下角,表7.1),我们将在下一章讨论测试它。

The refactoring has eliminated all code with high complexity and a large number of collaborators (top-right quadrant, table 7.1), so we have nothing to test there, either. As for the controllers quadrant (bottom-right, table 7.1), we’ll discuss testing it in the next chapter.

7.3.3 你应该测试先决条件吗?

7.3.3  Should you test preconditions?

让我们来看看一种特殊的分支点——先决条件——看看你是否应该测试它们。比如Company再看一遍这个方法:

Let’s take a look at a special kind of branching points — preconditions — and see whether you should test them. For example, look at this method from Company once again:

公共无效 ChangeNumberOfEmployees(int 增量)
{
    Precondition.Requires(NumberOfEmployees + delta >= 0);

    NumberOfEmployees += 增量;
}
public void ChangeNumberOfEmployees(int delta)
{
    Precondition.Requires(NumberOfEmployees + delta >= 0);

    NumberOfEmployees += delta;
}

它有一个前提条件,即公司的员工人数永远不会变为负数。此先决条件是一种仅在特殊情况下才会激活的保护措施。这种异常情况通常是错误的结果。员工人数低于零的唯一可能原因是代码中存在错误。安全措施为您的软件提供了一种快速失败的机制,并防止错误在数据库中传播和持久化,在数据库中处理起来要困难得多。你应该测试这样的先决条件吗?换句话说,这样的测试是否足够有价值以包含在测试套件中?

It has a precondition stating that the number of employees in the company should never become negative. This precondition is a safeguard that’s activated only in exceptional cases. Such exceptional cases are usually the result of bugs. The only possible reason for the number of employees to go below zero is if there’s an error in code. The safeguard provides a mechanism for your software to fail fast and to prevent the error from spreading and being persisted in the database, where it would be much harder to deal with. Should you test such preconditions? In other words, would such tests be valuable enough to have in the test suite?

这里没有硬性规定,但我建议您遵循的一般准则是测试所有具有领域意义的先决条件。员工人数非负数的要求就是这样一个前提。它是Company类的不变量的一部分:应该始终成立的条件。但是不要花时间测试没有领域意义的先决条件。例如,UserFactory在其方法中有如下保障Create

There’s no hard rule here, but the general guideline I recommend you follow is to test all preconditions that have domain significance. The requirement for the non-negative number of employees is such a precondition. It’s part of the Company class’s invariants: conditions that should be held true at all times. But don’t spend time testing preconditions that don’t have domain significance. For example, UserFactory has the following safeguard in its Create method:

公共静态用户创建(对象[]数据)
{
    Precondition.Requires(data.Length >= 3);

    /* 从数据中提取 id、email 和 type */
}
public static User Create(object[] data)
{
    Precondition.Requires(data.Length >= 3);

    /* Extract id, email, and type out of data */
}

这个先决条件没有领域意义,因此测试它没有太大价值。

There’s no domain meaning to this precondition and therefore not much value in testing it.

7.4 在控制器中处理条件逻辑

7.4  Handling conditional logic in controllers

处理条件逻辑并同时维护不受进程外协作者影响的领域层通常很棘手,并且需要权衡取舍。在本节中,我将展示这些权衡是什么,以及如何决定在您自己的项目中选择其中的哪一个。

Handling conditional logic and simultaneously maintaining the domain layer free of out-of-process collaborators is often tricky and involves trade-offs. In this section, I’ll show what those trade-offs are and how to decide which of them to choose in your own project.

当业务操作具有三个不同的阶段时,业务逻辑和编排之间的分离效果最好:

The separation between business logic and orchestration works best when a business operation has three distinct stages:

  • 从存储中检索数据

  • Retrieving data from storage

  • 执行业务逻辑

  • Executing business logic

  • 将数据持久化回存储(图7.10

  • Persisting data back to the storage (figure 7.10)

图 7.10。当所有对进程外依赖项的引用都可以被推送到业务操作的边缘时,六边形和功能架构的效果最好。

Figure 7.10. Hexagonal and functional architectures work best when all references to out-of-process dependencies can be pushed to the edges of business operations.

CH07 图 10 条件 1

不过,在很多情况下,这些阶段并不那么明确。正如我们在第 6 章中讨论的那样,您可能需要根据决策过程的中间结果从进程外依赖项中查询其他数据(图 7.11 。写入进程外依赖项通常也取决于该结果。

There are a lot of situations where these stages aren’t as clearcut, though. As we discussed in chapter 6, you might need to query additional data from an out-of-process dependency based on an intermediate result of the decision-making process (figure 7.11). Writing to the out-of-process dependency often depends on that result, too.

图 7.11。当您需要在业务操作中间引用进程外依赖项时,六边形架构就不太适用了。

Figure 7.11. A hexagonal architecture doesn’t work as well when you need to refer to out-of-process dependencies in the middle of the business operation.

CH07 图 11 cond2

正如上一章所讨论的,在这种情况下您有三种选择:

As also discussed in the previous chapter, you have three options in such a situation:

  • 无论如何将所有外部读取和写入推送到边缘。这种方法保留了 read-decide-act 结构,但牺牲了性能:即使不需要,控制器也会调用进程外依赖项。

  • Push all external reads and writes to the edges anyway. This approach preserves the read-decide-act structure but concedes performance: the controller will call out-of-process dependencies even when there’s no need for that.

  • 将进程外依赖项注入域模型,并允许业务逻辑直接决定何时调用这些依赖项。

  • Inject the out-of-process dependencies into the domain model and allow the business logic to directly decide when to call those dependencies.

  • 将决策过程拆分为更细化的步骤,并让控制器分别对每个步骤进行操作。

  • Split the decision-making process into more granular steps and have the controller act on each of those steps separately.

挑战在于平衡以下三个属性:

The challenge is to balance the following three attributes:

  • 域模型可测试性,它是域类中协作者的数量和类型的函数

  • Domain model testability, which is a function of the number and type of collaborators in domain classes

  • 控制器简单性,这取决于控制器中决策(分支)点的存在

  • Controller simplicity, which depends on the presence of decision-making (branching) points in the controller

  • 性能,由对进程外依赖项的调用次数定义

  • Performance, as defined by the number of calls to out-of-process dependencies

每个选项只给你三个属性中的两个(图7.12):

Each option only gives you two out of the three attributes (figure 7.12):

  • 将所有外部读取和写入推送到业务操作的边缘 ——保持控制器的简单性并使域模型与进程外依赖项隔离(从而使其保持可测试性),但会牺牲性能。

  • Pushing all external reads and writes to the edges of a business operation — Preserves controller simplicity and keeps the domain model isolated from out-of-process dependencies (thus allowing it to remain testable) but concedes performance.

  • 将进程外依赖项注入域模型 ——保持性能和控制器的简单性不变,但会损害域模型的可测试性。

  • Injecting out-of-process dependencies into the domain model — Keeps performance and the controller’s simplicity intact but damages domain model testability.

  • 将决策过程拆分为更细粒度的步骤 ——有助于提高性能和域模型的可测试性,但会牺牲控制器的简单性。您需要在控制器中引入决策点,以便管理这些细粒度的步骤。

  • Splitting the decision-making process into more granular steps — Helps with both performance and domain model testability but concedes controller simplicity. You’ll need to introduce decision-making points in the controller in order to manage these granular steps.

图 7.12。没有一种解决方案可以满足所有三个属性:控制器简单性、域模型可测试性和性能。你必须从三个中选择两个。

Figure 7.12. There’s no single solution that satisfies all three attributes: controller simplicity, domain model testability, and performance. You have to choose two out of the three.

CH07 图12 cond3

在大多数软件项目中,性能重要,因此第一种方法(将外部读写推到业务操作的边缘)是不可能的。第二个选项(将进程外依赖项注入域模型)将大部分代码带入代码类型图上过于复杂的象限。这正是我们重构初始 CRM 实施的目的。我建议您避免这种方法:这样的代码不再保留业务逻辑与进程外依赖通信之间的分离,因此变得更难测试和维护。

In most software projects, performance is important, so the first approach (pushing external reads and writes to the edges of a business operation) is out of the question. The second option (injecting out-of-process dependencies into the domain model) brings most of your code into the overcomplicated quadrant on the diagram of types of code. This is exactly what we refactored the initial CRM implementation away from. I recommend that you avoid this approach: such code no longer preserves the separation between business logic and communication with out-of-process dependencies and thus becomes much harder to test and maintain.

这就给你留下了第三种选择:将决策过程分成更小的步骤。使用这种方法,您将不得不使您的控制器更加复杂,这也会将它们推向过于复杂的象限。但是有一些方法可以缓解这个问题。尽管您很少能够像我们之前在示例项目中所做的那样从控制器中排除所有复杂性,但您可以使这种复杂性保持在可控范围内。

That leaves you with the third option: splitting the decision-making process into smaller steps. With this approach, you will have to make your controllers more complex, which will also push them closer to the overcomplicated quadrant. But there are ways to mitigate this problem. Although you will rarely be able to factor all the complexity out of controllers as we did previously in the sample project, you can keep that complexity manageable.

7.4.1 使用 CanExecute/Execute 模式

7.4.1  Using the CanExecute/Execute pattern

缓解控制器复杂性增长的第一种方法是使用 CanExecute/Execute 模式,这有助于避免业务逻辑从域模型泄漏到控制器。这个模式最好用一个例子来解释,所以让我们扩展我们的示例项目。

The first way to mitigate the growth of the controllers' complexity is to use the CanExecute/Execute pattern, which helps avoid leaking of business logic from the domain model to controllers. This pattern is best explained with an example, so let’s expand on our sample project.

假设用户只有在确认之前才能更改他们的电子邮件。如果用户在确认后尝试更改电子邮件,则应向他们显示一条错误消息。为了满足这一新要求,我们将向该类添加一个新属性User

Let’s say that a user can change their email only until they confirm it. If a user tries to change the email after the confirmation, they should be shown an error message. To accommodate this new requirement, we’ll add a new property to the User class.

清单 7.7。 User有了新的财产

Listing 7.7. User with a new property

公共类用户
{
    public int UserId { 得到; 私有集;}
    公共字符串电子邮件 { 得到; 私有集;}
    公共 UserType 类型 { 得到; 私有集;}
    公共布尔 IsEmailConfirmed    
        { 得到; 私有集;}      

    /* ChangeEmail(newEmail, company) 方法 */
}
public class User
{
    public int UserId { get; private set; }
    public string Email { get; private set; }
    public UserType Type { get; private set; }
    public bool IsEmailConfirmed   
        { get; private set; }      

    /* ChangeEmail(newEmail, company) method */
}

新物业

New property

将此支票放在哪里有两种选择。首先,你可以把它放在 的User方法中ChangeEmail

There are two options for where to put this check. First, you could put it in User's ChangeEmail method:

public string ChangeEmail(string newEmail, Company 公司)
{
    如果(IsEmailConfirmed)
        返回“无法更改已确认的电子邮件”;

    /* 方法的其余部分 */
}
public string ChangeEmail(string newEmail, Company company)
{
    if (IsEmailConfirmed)
        return "Can't change a confirmed email";

    /* the rest of the method */
}

然后,您可以让控制器返回错误或产生所有必要的副作用,具体取决于此方法的输出。

Then you could make the controller either return an error or incur all necessary side effects, depending on this method’s output.

清单 7.8。控制器,仍然被剥夺了所有的决策权

Listing 7.8. The controller, still stripped of all decision-making

public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);       
    用户user = UserFactory.Create(userData);                

    对象 [] 公司数据 = _database.GetCompany();           
    公司 company = CompanyFactory.Create(companyData);    

    string error = user.ChangeEmail(newEmail, company);       
    if (error != null)                                       
        返回错误;                                        

    _database.SaveCompany(公司);                           
    _database.SaveUser(用户);                                 
    _messageBus.SendEmailChangedMessage(userId, newEmail);   

    返回“确定”;                                              
}
public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);       
    User user = UserFactory.Create(userData);                

    object[] companyData = _database.GetCompany();           
    Company company = CompanyFactory.Create(companyData);    

    string error = user.ChangeEmail(newEmail, company);      
    if (error != null)                                       
        return error;                                        

    _database.SaveCompany(company);                          
    _database.SaveUser(user);                                
    _messageBus.SendEmailChangedMessage(userId, newEmail);   

    return "OK";                                             
}

准备数据

Prepares the data

做出决定

Makes a decision

对决定采取行动

Acts on the decision

此实现使控制器无需做出决策,但这样做会以性能缺陷为代价:实例Company会无条件地从数据库中检索,即使电子邮件已确认且因此无法更改。这是将所有外部读取和写入推送到业务操作边缘的示例。

This implementation keeps the controller free of decision-making, but it does so at the expense of a performance drawback: the Company instance is retrieved from the database unconditionally, even when the email is confirmed and thus can’t be changed. This is an example of pushing all external reads and writes to the edges of a business operation.

V03 我不认为if分析字符串的新语句error增加了复杂性,因为它属于执行阶段;它不是决策过程的一部分。所有的决定都是由User类做出的,控制器只对这些决定采取行动。

V03I don’t consider the new if statement analyzing the error string an increase in complexity because it belongs to the acting phase; it’s not part of the decision-making process. All the decisions are made by the User class, and the controller merely acts on those decisions.

第二个选项是将检查IsEmailConfirmed从移动User到控制器:

The second option is to move the check for IsEmailConfirmed from User to the controller:

清单 7.9。控制器决定是否更改用户的电子邮件

Listing 7.9. Controller deciding whether to change the user’s email

public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    用户 user = UserFactory.Create(userData);

    如果(用户。IsEmailConfirmed)                     
        返回“无法更改已确认的电子邮件”;   

    对象 [] 公司数据 = _database.GetCompany();
    公司公司 = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(公司);
    _database.SaveUser(用户);
    _messageBus.SendEmailChangedMessage(userId, newEmail);

    返回“确定”;
}
public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    User user = UserFactory.Create(userData);

    if (user.IsEmailConfirmed)                     
        return "Can't change a confirmed email";   

    object[] companyData = _database.GetCompany();
    Company company = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(company);
    _database.SaveUser(user);
    _messageBus.SendEmailChangedMessage(userId, newEmail);

    return "OK";
}

决策从用户转移到这里。

Decision-making moved here from User.

使用此实现,性能保持不变:Company只有在确定电子邮件可以更改后,才会从数据库中检索实例。但现在决策过程分为两部分:

With this implementation, the performance stays intact: the Company instance is retrieved from the database only after it is certain that the email can be changed. But now the decision-making process is split into two parts:

  • 是否继续更改电子邮件(由控制者执行)

  • Whether to proceed with the change of email (performed by the controller)

  • 在该更改期间做什么(由执行User

  • What to do during that change (performed by User)

现在也可以在不先验证标志的情况下更改电子邮件IsEmailConfirmed,这减少了域模型的封装。这种碎片化阻碍了业务逻辑和编排之间的分离,并使控制器更接近过于复杂的危险区域。

Now it’s also possible to change the email without verifying the IsEmailConfirmed flag first, which diminishes the domain model’s encapsulation. Such fragmentation hinders the separation between business logic and orchestration and moves the controller closer to the overcomplicated danger zone.

User为了防止这种碎片化,你可以在,中引入一个新的方法CanChangeEmail(),并将其成功执行作为更改电子邮件的前提条件。以下清单中的修改版本遵循 CanExecute/Execute 模式。

To prevent this fragmentation, you can introduce a new method in User, CanChangeEmail(), and make its successful execution a precondition for changing an email. The modified version in the following listing follows the CanExecute/Execute pattern.

清单 7.10。按照 CanExecute/Execute 模式更改电子邮件

Listing 7.10. Changing an email by following the CanExecute/Execute pattern

公共字符串 CanChangeEmail()
{
    如果(IsEmailConfirmed)
        返回“无法更改已确认的电子邮件”;

    返回空值;
}

public void ChangeEmail(string newEmail, Company 公司)
{
    Precondition.Requires(CanChangeEmail() == null);

    /* 方法的其余部分 */
}
public string CanChangeEmail()
{
    if (IsEmailConfirmed)
        return "Can't change a confirmed email";

    return null;
}

public void ChangeEmail(string newEmail, Company company)
{
    Precondition.Requires(CanChangeEmail() == null);

    /* the rest of the method */
}

这种方法有两个重要的好处:

This approach provides two important benefits:

  • 控制器不再需要了解有关更改电子邮件的过程的任何信息。它所需要做的就是调用该CanChangeEmail()方法以查看是否可以完成操作。请注意,此方法可以包含多个验证,所有验证都封装在控制器之外。

  • The controller no longer needs to know anything about the process of changing emails. All it needs to do is call the CanChangeEmail() method to see if the operation can be done. Notice that this method can contain multiple validations, all encapsulated away from the controller.

  • 中的附加前提条件ChangeEmail()保证在不先检查确认的情况下永远不会更改电子邮件。

  • The additional precondition in ChangeEmail() guarantees that the email won’t ever be changed without checking for the confirmation first.

此模式可帮助您整合领域层中的所有决策。控制器不再具有不检查电子邮件确认的选项,这从本质上消除了该控制器的新决策点。因此,尽管控制器仍然包含if调用语句CanChangeEmail(),但您不需要测试该if语句。对User类本身的前提条件进行单元测试就足够了。

This pattern helps you to consolidate all decisions in the domain layer. The controller no longer has an option not to check for the email confirmation, which essentially eliminates the new decision-making point from that controller. Thus, although the controller still contains the if statement calling CanChangeEmail(), you don’t need to test that if statement. Unit testing the precondition in the User class itself is enough.

V03 为了简单起见,我使用 astring来表示错误。在实际项目中,您可能希望引入一个自定义Result类来指示操作的成功或失败。

V03For simplicity’s sake, I’m using a string to denote an error. In a real-world project, you may want to introduce a custom Result class to indicate the success or failure of an operation.

7.4.2 使用领域事件跟踪领域模型的变化

7.4.2  Using domain events to track changes in the domain model

有时很难推断出是什么步骤导致域模型达到当前状态。不过,了解这些步骤可能仍然很重要,因为您需要通知外部系统您的应用程序中到底发生了什么。将此责任放在控制器上会使这些变得更加复杂。为避免这种情况,您可以跟踪域模型中的重要更改,然后在业务操作完成后将这些更改转换为对进程外依赖项的调用。领域事件可以帮助您实现此类跟踪。

It’s sometimes hard to deduct what steps led the domain model to the current state. Still, it might be important to know these steps because you need to inform external systems about what exactly has happened in your application. Putting this responsibility on the controllers would make those more complicated. To avoid that, you can track important changes in the domain model and then convert those changes into calls to out-of-process dependencies after the business operation is complete. Domain events help you implement such tracking.

定义 7.1:

Definition 7.1:

领域事件描述应用程序中对领域专家有意义的事件。领域专家的意义在于将领域事件与常规事件(例如按钮点击)区分开来。域事件通常用于通知外部应用程序有关系统中发生的重要更改。

A domain event describes an event in the application that is meaningful to domain experts. The meaningfulness for domain experts is what differentiates domain events from regular events (such as button clicks). Domain events are often used to inform external applications about important changes that have happened in your system.

我们的 CRM 也有跟踪要求:它必须通过向消息总线发送消息来通知外部系统有关更改的用户电子邮件。当前的实现在通知功能中存在缺陷:即使电子邮件未更改,它也会发送消息。

Our CRM has a tracking requirement, too: it has to notify external systems about changed user emails by sending messages to the message bus. The current implementation has a flaw in the notification functionality: it sends messages even when the email is not changed.

清单 7.11。即使电子邮件未更改也发送通知

Listing 7.11. Sends a notification even when the email has not changed

// 用户
public void ChangeEmail(string newEmail, Company 公司)
{
    Precondition.Requires(CanChangeEmail() == null);

    如果(电子邮件 == 新电子邮件)   
        返回;

    /* 方法的其余部分 */
}

// 控制器
public string ChangeEmail(int userId, string newEmail)
{
    /* 准备工作 */

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(公司);
    _database.SaveUser(用户);
    _messageBus.SendEmailChangedMessage(    
        userId, newEmail);                 

    返回“确定”;
}
// User
public void ChangeEmail(string newEmail, Company company)
{
    Precondition.Requires(CanChangeEmail() == null);

    if (Email == newEmail)   
        return;

    /* the rest of the method */
}

// Controller
public string ChangeEmail(int userId, string newEmail)
{
    /* preparations */

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(company);
    _database.SaveUser(user);
    _messageBus.SendEmailChangedMessage(   
        userId, newEmail);                 

    return "OK";
}

用户电子邮件可能不会更改。

User email may not change.

无论如何,控制器都会发送一条消息。

The controller sends a message anyway.

您可以通过将电子邮件相同性检查移至控制器来解决此错误,但话又说回来,业务逻辑碎片化存在问题。而且您不能进行此检查,CanChangeEmail()因为如果新电子邮件与旧电子邮件相同,应用程序不应返回错误。

You could resolve this bug by moving the check for email sameness to the controller, but then again, there are issues with the business logic fragmentation. And you can’t put this check to CanChangeEmail() because the application shouldn’t return an error if the new email is the same as the old one.

请注意,此特定检查可能不会引入太多业务逻辑碎片,因此如果包含该检查,我个人不会认为控制器过于复杂。但是您可能会发现自己处于更困难的境地,在这种情况下,很难防止您的应用程序在不将这些依赖项传递给域模型的情况下对进程外依赖项进行不必要的调用,从而使该域模型过于复杂。防止这种过度复杂化的唯一方法是使用域事件。

Note that this particular check probably doesn’t introduce too much business logic fragmentation, so I personally wouldn’t consider the controller overcomplicated if it contained that check. But you may find yourself in a more difficult situation in which it’s hard to prevent your application from making unnecessary calls to out-of-process dependencies without passing those dependencies to the domain model, thus overcomplicating that domain model. The only way to prevent such overcomplication is the use of domain events.

从实现的角度来看,领域事件是一个包含通知外部系统所需数据​​的类。在我们的具体示例中,它是用户的 ID 和电子邮件:

From an implementation standpoint, a domain event is a class that contains data needed to notify external systems. In our specific example, it is the user’s ID and email:

公共类 EmailChangedEvent
{
    public int UserId { 得到; }
    公共字符串 NewEmail { 得到; }
}
public class EmailChangedEvent
{
    public int UserId { get; }
    public string NewEmail { get; }
}

V03Domain 事件应始终以过去时命名,因为它们代表已经发生的事情。领域事件是值——它们是不可变的和可互换的。

V03Domain events should always be named in the past tense because they represent things that already happened. Domain events are values — they are immutable and interchangeable.

User将具有此类事件的集合,当电子邮件更改时,它将向其中添加一个新元素。这就是它的ChangeEmail()方法在重构后的样子。

User will have a collection of such events to which it will add a new element when the email changes. This is how its ChangeEmail() method looks after the refactoring.

清单 7.12。 User在电子邮件更改时添加事件

Listing 7.12. User adding an event when the email changes

public void ChangeEmail(string newEmail, Company 公司)
{
    Precondition.Requires(CanChangeEmail() == null);

    如果(电子邮件 == 新电子邮件)
        返回;

    用户类型 newType = company.IsEmailCorporate(newEmail)
        ?用户类型.员工
        : 用户类型.客户;

    如果(类型!=新类型)
    {
        int delta = newType == UserType.Employee ?1 : -1;
        公司.ChangeNumberOfEmployees(增量);
    }

    邮箱 = newEmail;
    类型 = 新类型;
    EmailChangedEvents.Add(                          
        new EmailChangedEvent(UserId, newEmail));    
}
public void ChangeEmail(string newEmail, Company company)
{
    Precondition.Requires(CanChangeEmail() == null);

    if (Email == newEmail)
        return;

    UserType newType = company.IsEmailCorporate(newEmail)
        ? UserType.Employee
        : UserType.Customer;

    if (Type != newType)
    {
        int delta = newType == UserType.Employee ? 1 : -1;
        company.ChangeNumberOfEmployees(delta);
    }

    Email = newEmail;
    Type = newType;
    EmailChangedEvents.Add(                         
        new EmailChangedEvent(UserId, newEmail));   
}

新事件表示电子邮件的更改。

A new event indicates the change of email.

然后,控制器会将事件转换为总线上的消息。

The controller then will convert the events into messages on the bus.

清单 7.13。控制器处理域事件

Listing 7.13. The controller processing domain events

public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    用户 user = UserFactory.Create(userData);

    字符串错误 = user.CanChangeEmail();
    如果(错误!= null)
        返回错误;

    对象 [] 公司数据 = _database.GetCompany();
    公司公司 = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(公司);
    _database.SaveUser(用户);
    foreach(user.EmailChangedEvents 中的 var ev)    
    {                                              
        _messageBus.SendEmailChangedMessage(       
            ev.UserId, ev.NewEmail);               
    }                                             

    返回“确定”;
}
public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    User user = UserFactory.Create(userData);

    string error = user.CanChangeEmail();
    if (error != null)
        return error;

    object[] companyData = _database.GetCompany();
    Company company = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(company);
    _database.SaveUser(user);
    foreach (var ev in user.EmailChangedEvents)   
    {                                             
        _messageBus.SendEmailChangedMessage(      
            ev.UserId, ev.NewEmail);              
    }                                             

    return "OK";
}

领域事件处理

Domain event processing

请注意,CompanyUser实例仍然无条件地持久化在数据库中:持久化逻辑不依赖于域事件。这是由于数据库中的更改和总线中的消息之间存在差异。

Notice that the Company and User instances are still persisted in the database unconditionally: the persistence logic doesn’t depend on domain events. This is due to the difference between changes in the database and messages in the bus.

假设除了 CRM 之外没有其他应用程序可以访问数据库,与该数据库的通信不是 CRM 可观察行为的一部分——它们是实现细节。只要数据库的最终状态是正确的,您的应用程序对该数据库进行多少次调用都无关紧要。另一方面,与消息总线的通信应用程序可观察行为的一部分。为了维护与外部系统的合同,CRM 应该仅在电子邮件更改时才将消息放在总线上。

Assuming that no application has access to the database other than the CRM, communications with that database are not part of the CRM’s observable behavior — they are implementation details. As long as the final state of the database is correct, it doesn’t matter how many calls your application makes to that database. On the other hand, communications with the message bus are part of the application’s observable behavior. In order to maintain the contract with external systems, the CRM should put messages on the bus only when the email changes.

无条件地在数据库中持久化数据对性能有影响,但它们相对微不足道。在所有验证之后,新电子邮件与旧电子邮件相同的可能性非常小。使用 ORM 也有帮助。如果对象状态没有变化,大多数 ORM 不会往返数据库。

There are performance implications to persisting data in the database unconditionally, but they are relatively insignificant. The chances that after all the validations the new email is the same as the old one are quite small. The use of an ORM can also help. Most ORMs won’t make a round trip to the database if there are no changes to the object state.

您可以使用域事件概括解决方案:提取DomainEvent基类并为所有域类引入基类,其中将包含此类事件的集合:List<DomainEvent> events。您还可以编写一个单独的事件调度程序,而不是在控制器中手动调度域事件。最后,在较大的项目中,您可能需要一种机制来在分派域事件之前合并域事件。不过,该主题超出了本书的范围。您可以在http://mng.bz/YeVe上我的文章“调度前合并域事件”中阅读相关内容。

You can generalize the solution with domain events: extract a DomainEvent base class and introduce a base class for all domain classes, which would contain a collection of such events: List<DomainEvent> events. You can also write a separate event dispatcher instead of dispatching domain events manually in controllers. Finally, in larger projects, you might need a mechanism for merging domain events before dispatching them. That topic is outside the scope of this book, though. You can read about it in my article "Merging domain events before dispatching" at http://mng.bz/YeVe.

域事件从控制器中移除了决策制定责任,并将该责任置于域模型中,从而简化了与外部系统的单元测试通信。您可以直接在单元测试中测试域事件创建,而不是验证控制器本身并使用模拟来替代进程外依赖项。

Domain events remove the decision-making responsibility from the controller and put that responsibility into the domain model, thus simplifying unit testing communications with external systems. Instead of verifying the controller itself and using mocks to substitute out-of-process dependencies, you can test the domain event creation directly in unit tests.

清单 7.14。测试域事件的创建

Listing 7.14. Testing the creation of a domain event

[事实]
public void Changing_email_from_corporate_to_non_corporate()
{
    var company = new Company("mycorp.com", 1);
    var sut = new User(1, "user@mycorp.com", UserType.Employee, false);

    sut.ChangeEmail("new@gmail.com", 公司);

    公司.NumberOfEmployees.Should().Be(0);
    sut.Email.Should().Be("new@gmail.com");
    sut.Type.Should().Be(UserType.Customer);
    sut.EmailChangedEvents.Should().Equal(            
        new EmailChangedEvent(1, "new@gmail.com"));   
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    var company = new Company("mycorp.com", 1);
    var sut = new User(1, "user@mycorp.com", UserType.Employee, false);

    sut.ChangeEmail("new@gmail.com", company);

    company.NumberOfEmployees.Should().Be(0);
    sut.Email.Should().Be("new@gmail.com");
    sut.Type.Should().Be(UserType.Customer);
    sut.EmailChangedEvents.Should().Equal(           
        new EmailChangedEvent(1, "new@gmail.com"));  
}

同时断言集合大小和集合中的元素

Simultaneously asserts the collection size and the element in the collection

当然,您仍然需要测试控制器以确保它正确执行编排,但这样做需要的测试集要少得多。这就是下一章的主题。

Of course, you’ll still need to test the controller to make sure it does the orchestration correctly, but doing so requires a much smaller set of tests. That’s the topic of the next chapter.

7.4.3 结论

7.4.3  Conclusion

请注意贯穿本章的一个主题:将副作用应用抽象到外部系统。您可以通过将这些副作用保留在内存中直到业务操作结束来实现这种抽象,以便可以使用普通单元测试对它们进行测试,而无需涉及进程外依赖性。领域事件是对总线中即将到来的消息的抽象。域类中的更改是对数据库中即将进行的修改的抽象。

Notice a theme that has been present throughout this chapter: abstracting away the application of side effects to external systems. You achieve such abstraction by keeping those side effects in memory until the very end of the business operation, so that they can be tested with plain unit tests without involving out-of-process dependencies. Domain events are abstractions on top of upcoming messages in the bus. Changes in domain classes are abstractions on top of upcoming modifications in the database.

V03 测试抽象比他们抽象的东西更容易。

V03It’s easier to test abstractions than the things they abstract.

尽管我们能够借助域事件和 CanExecute/Execute 模式成功地将所有决策制定包含在域模型中,但您无法始终做到这一点。在某些情况下,业务逻辑碎片化是不可避免的。

Although we were able to successfully contain all the decision-making in the domain model with the help of domain events and the CanExecute/Execute pattern, you won’t be able to always do that. There are situations where business logic fragmentation is inevitable.

例如,如果不在域模型中引入进程外依赖项,就无法在控制器外部验证电子邮件的唯一性。另一个例子是应该改变业务操作过程的进程外依赖性失败。关于走哪条路的决定不能驻留在域层中,因为它不是调用那些进程外依赖项的域层。您必须将此逻辑放入控制器中,然后用集成测试覆盖它。尽管如此,即使存在潜在的碎片化,将业务逻辑与编排分离仍然具有很大的价值,因为这种分离极大地简化了单元测试过程。

For example, there’s no way to verify email uniqueness outside the controller without introducing out-of-process dependencies in the domain model. Another example is failures in out-of-process dependencies that should alter the course of the business operation. The decision about which way to go can’t reside in the domain layer because it’s not the domain layer that calls those out-of-process dependencies. You will have to put this logic into controllers and then cover it with integration tests. Still, even with the potential fragmentation, there’s a lot of value in separating business logic from orchestration because this separation drastically simplifies the unit testing process.

正如您无法避免在控制器中包含一些业务逻辑一样,您也很少能够从域类中删除所有协作者。这很好。一个、两个甚至三个协作者不会将域类变成过于复杂的代码,只要这些协作者不引用进程外依赖项。

Just as you can’t avoid having some business logic in controllers, you will rarely be able to remove all collaborators from domain classes. And that’s fine. One, two, or even three collaborators won’t turn a domain class into overcomplicated code, as long as these collaborators don’t refer to out-of-process dependencies.

不过,不要使用模拟来验证与此类协作者的交互。这些交互与域模型的可观察行为无关。只有从控制器到域类的第一个调用与该控制器的目标有直接联系。域类在同一操作中对其相邻域类进行的所有后续调用都是实现细节。

Don’t use mocks to verify interactions with such collaborators, though. These interactions have nothing to do with the domain model’s observable behavior. Only the very first call, which goes from a controller to a domain class, has an immediate connection to that controller’s goal. All the subsequent calls the domain class makes to its neighbor domain classes within the same operation are implementation details.

7.13说明了这个想法。它显示了 CRM 中组件之间的通信及其与可观察行为的关系。你可能还记得第 5 章,一个方法是否是类的可观察行为的一部分取决于客户是谁以及客户的目标是什么。要成为可观察行为的一部分,该方法必须满足以下两个条件之一:

Figure 7.13 illustrates this idea. It shows the communications between components in the CRM and their relationship to observable behavior. As you may remember from chapter 5, whether a method is part of the class’s observable behavior depends on whom the client is and what the goals of that client are. To be part of the observable behavior, the method must meet one of the following two criteria:

  • 与客户的目标之一有直接联系

  • Have an immediate connection to one of the client’s goals

  • 在外部应用程序可见的进程外依赖项中产生副作用

  • Incur a side effect in an out-of-process dependency that is visible to external applications

图 7.13。一张地图,显示 CRM 中组件之间的通信以及这些通信与可观察行为之间的关系

Figure 7.13. A map that shows communications among components in the CRM and the relationship between these communications and observable behavior

CH07 图13 结论

控制器的ChangeEmail()方法是其可观察行为的一部分,它对消息总线的调用也是如此。第一种方法是外部客户端的入口点,因此满足第一个标准。对总线的调用将消息发送到外部应用程序,从而满足第二个标准。您应该验证这两个方法调用(这是下一章的主题)。但是,控制器对 的后续调用并User没有直接连接到外部客户端的目标。只要系统的最终状态正确并且对消息总线的调用到位,该客户端就不会关心控制器如何决定实施电子邮件的更改。因此,您不应该验证控制器对User在测试该控制器的行为时。

The controller’s ChangeEmail() method is part of its observable behavior, and so is the call it makes to the message bus. The first method is the entry point for the external client, thereby meeting the first criterion. The call to the bus sends messages to external applications, thereby meeting the second criterion. You should verify both of these method calls (which is the topic of the next chapter). However, the subsequent call from the controller to User doesn’t have an immediate connection to the goals of the external client. That client doesn’t care how the controller decides to implement the change of email as long as the final state of the system is correct and the call to the message bus is in place. Therefore, you shouldn’t verify calls that the controller makes to User when testing that controller’s behavior.

当您在调用堆栈中向下移动一级时,您会遇到类似的情况。现在控制器是客户端,其中的ChangeEmail方法与User该客户端更改用户电子邮件的目标有直接联系,因此应该进行测试。但从控制器的角度来看,后续调用是实现User细节。因此,涵盖方法的Company测试不应验证调用了哪些方法。当您再降一级并从的角度测试这两种方法时,同样的推理也适用。ChangeEmailUserUserCompanyCompanyUser

When you step one level down the call stack, you get a similar situation. Now it’s the controller who is the client, and the ChangeEmail method in User has an immediate connection to that client’s goal of changing the user email and thus should be tested. But the subsequent calls from User to Company are implementation details from the controller’s point of view. Therefore, the test that covers the ChangeEmail method in User shouldn’t verify what methods User calls on Company. The same line of reasoning applies when you step one more level down and test the two methods in Company from User's point of view.

将可观察到的行为和实现细节想象成洋葱层。从外层的角度测试每一层,而不管该层如何与底层通信。当你一层一层地剥离这些层时,你会转换视角:以前的实现细节现在变成了可观察的行为,然后你用另一组测试覆盖它。

Think of the observable behavior and implementation details as onion layers. Test each layer from the outer layer’s point of view, and disregard how that layer talks to the underlying layers. As you peel these layers one by one, you switch perspective: what previously was an implementation detail now becomes an observable behavior, which you then cover with another set of tests.

7.5 总结

7.5  Summary

  • 代码复杂性由代码中决策点的数量定义,包括显式(由代码本身做出)和隐式(由代码使用的库做出)。

  • Code complexity is defined by the number of decision-making points in the code, both explicit (made by the code itself) and implicit (made by the libraries the code uses).

  • 域重要性显示代码对于项目的问题域的重要性。复杂代码通常具有很高的领域意义,反之亦然,但并非在所有情况下都是 100%。

  • Domain significance shows how significant the code is for the problem domain of your project. Complex code often has high domain significance and vice versa, but not in 100% of all cases.

  • 复杂代码和具有领域重要性的代码从单元测试中获益最多,因为相应的测试可以更好地防止回归。

  • Complex code and code that has domain significance benefit from unit testing the most because the corresponding tests have greater protection against regressions.

  • 涉及大量协作者的代码单元测试维护成本很高。此类测试需要大量空间来将协作者带到预期状态,然后检查他们的状态或之后与他们的交互。

  • Unit tests that cover code with a large number of collaborators have high maintenance costs. Such tests require a lot of space to bring collaborators to an expected condition and then check their state or interactions with them afterward.

  • 所有生产代码都可以根据其复杂性或领域重要性以及协作者的数量分为四种类型的代码:

    • 领域模型和算法(高复杂性或领域重要性,很少的合作者)提供单元测试工作的最佳回报。

    • 琐碎的代码(低复杂性和领域重要性,很少的合作者)根本不值得测试。

    • 控制器(低复杂性和领域重要性,大量协作者)应该通过集成测试进行简要测试。

    • 过于复杂的代码(高复杂度或领域重要性、大量协作者)应该拆分为控制器和复杂代码。

  • All production code can be categorized into four types of code by its complexity or domain significance and the number of collaborators:

    • Domain model and algorithms (high complexity or domain significance, few collaborators) provides the best return on unit testing efforts.

    • Trivial code (low complexity and domain significance, few collaborators) isn’t worth testing at all.

    • Controllers (low complexity and domain significance, large number of collaborators) should be tested briefly by integration tests.

    • Overcomplicated code (high complexity or domain significance, large number of collaborators) should be split into controllers and complex code.

  • 代码越重要或越复杂,它应该拥有的协作者就越少。

  • The more important or complex the code is, the fewer collaborators it should have.

  • 谦虚对象模式通过将业务逻辑从代码中提取到一个单独的类中,帮助使过于复杂的代码可测试。结果,剩下的代码变成了一个控制器——一个围绕业务逻辑的薄而简陋的包装器。

  • The Humble Object pattern helps make overcomplicated code testable by extracting business logic out of that code into a separate class. As a result, the remaining code becomes a controller — a thin, humble wrapper around the business logic.

  • 六边形和函数式架构实现了低级对象模式。六边形架构提倡业务逻辑和通信与进程外依赖的分离。功能架构将业务逻辑与所有协作者的通信分开,而不仅仅是进程外的。

  • The hexagonal and functional architectures implement the Humble Object pattern. Hexagonal architecture advocates for the separation of business logic and communications with out-of-process dependencies. Functional architecture separates business logic from communications with all collaborators, not just out-of-process ones.

  • 从代码深度与代码宽度的角度考虑业务逻辑和编排责任。您的代码可以很深(复杂或重要)或很宽(与许多合作者一起工作),但绝不能两者兼而有之。

  • Think of the business logic and orchestration responsibilities in terms of code depth versus code width. Your code can be either deep (complex or important) or wide (work with many collaborators), but never both.

  • 测试先决条件是否具有领域意义;不要以其他方式测试它们。

  • Test preconditions if they have a domain significance; don’t test them otherwise.

  • 在将业务逻辑与编排分离时,存在三个重要属性:

    • 领域模型可测试性 ——领域类中协作者数量和类型的函数

    • 控制器的简单性 ——取决于控制器中决策点的存在

    • 性能 ——由调用进程外依赖项的次数定义

  • There are three important attributes when it comes to separating business logic from orchestration:

    • Domain model testability — A function of the number and the type of collaborators in domain classes

    • Controller simplicity — Depends on the presence of decision-making points in the controller

    • Performance — Defined by the number of calls to out-of-process dependencies

  • 在任何给定时刻,您最多可以拥有这三个属性中的两个:

    • 将所有外部读取和写入推送到业务操作的边缘 ——保持控制器的简单性并保持域模型的可测试性,但会牺牲性能。

    • 将进程外依赖项注入域模型 ——保持性能和控制器的简单性,但损害域模型的可测试性。

    • 将决策过程拆分为更细化的步骤 ——保留性能和领域模型的可测试性,但放弃了控制器的简单性。

  • You can have a maximum of two of these three attributes at any given moment:

    • Pushing all external reads and writes to the edges of a business operation — Preserves controller simplicity and keeps the domain model testability, but concedes performance.

    • Injecting out-of-process dependencies into the domain model — Keeps performance and the controller’s simplicity, but damages domain model testability.

    • Splitting the decision-making process into more granular steps — Preserves performance and domain model testability, but gives up controller simplicity.

  • 将决策过程拆分为更细化的步骤 ——是一种最佳利弊权衡。您可以使用以下两种模式来缓解控制器复杂性的增长:

    • CanExecute/Execute 模式CanDo()为每个Do()方法引入了一个,并使其成功执行成为Do(). 这种模式基本上消除了控制器的决策,因为没有不调用CanDo()before 的选项Do()

    • 域事件有助于跟踪域模型中的重要更改,然后将这些更改转换为对进程外依赖项的调用。这种模式消除了控制器的跟踪责任。

  • Splitting the decision-making process into more granular steps — Is a trade-off with the best set of pros and cons. You can mitigate the growth of controller complexity using the following two patterns:

    • The CanExecute/Execute pattern introduces a CanDo() for each Do() method and makes its successful execution a precondition for Do(). This pattern essentially eliminates the controller’s decision-making because there’s no option not to call CanDo() before Do().

    • Domain events help track important changes in the domain model, and then convert those changes to calls to out-of-process dependencies. This pattern removes the tracking responsibility from the controller.

  • 测试抽象比测试抽象的东西更容易。域事件是对即将到来的进程外依赖项调用的抽象。领域类的变化是对即将到来的数据存储修改的抽象。

  • It’s easier to test abstractions than the things they abstract. Domain events are abstractions on top of upcoming calls to out-of-process dependencies. Changes in domain classes are abstractions on top of upcoming modifications in the data storage.

第 3 部分

Part 3

集成测试

Integration testing

您是否遇到过所有单元测试都通过但应用程序仍然无法运行的情况?相互隔离地验证软件组件很重要,但检查这些组件如何与外部系统集成也同样重要。这就是集成测试发挥作用的地方。

Have you ever been in a situation where all unit tests pass but the application doesn’t work nonetheless? Validating software components in isolation from each other is important, but it’s equally important to check how those components work in integration with external systems. This is where integration testing comes into play.

在第 8 章中,我们将全面了解集成测试并重新审视测试金字塔的概念。您将了解集成测试固有的权衡以及如何驾驭它们。第 9 章和第 10 章将讨论更具体的主题。第 9 章将教您如何充分利用模拟。第 10 章深入探讨了在测试中使用关系数据库。

In chapter 8, we’ll look at integration testing in general and revisit the Test Pyramid concept. You’ll learn the trade-offs inherent to integration testing and how to navigate them. Chapters 9 and 10 will then discuss more specific topics. Chapter 9 will teach you how to get the most out of your mocks. Chapter 10 is a deep dive into working with relational databases in tests.

8

8

为什么要进行集成测试?

Why integration testing?

本章涵盖:

This chapter covers:

  • 了解集成测试的作用

  • Understanding the role of integration testing

  • 深入了解测试金字塔概念

  • Diving deeper into the Test Pyramid concept

  • 编写有价值的集成测试

  • Writing valuable integration tests

如果你完全依赖单元测试,你永远无法确定你的系统作为一个整体工作。单元测试非常适合验证业务逻辑,但在真空中检查该逻辑是不够的。您必须验证它的不同部分如何相互集成以及如何与外部系统集成:数据库、消息总线等。

You can never be sure your system works as a whole if you rely on unit tests exclusively. Unit tests are great at verifying business logic, but it’s not enough to check that logic in a vacuum. You have to validate how different parts of it integrate with each other and external systems: the database, the message bus, and so on.

在本章中,您将了解集成测试的作用:何时应该应用它们以及何时最好依赖普通的旧单元测试或什至其他技术,例如 Fail Fast 原则。您将看到在集成测试中按原样使用哪些进程外依赖项以及哪些要用模拟替换。您还将看到集成测试最佳实践,这些最佳实践将有助于总体上改善代码库的健康状况:使领域模型边界明确、减少应用程序中的层数以及消除循环依赖性。最后,您将了解为什么应偶尔使用具有单一实现的接口,以及如何以及何时测试日志记录功能。

In this chapter, you’ll learn the role of integration tests: when you should apply them and when it’s better to rely on plain old unit tests or even other techniques such as the Fail Fast principle. You will see which out-of-process dependencies to use as is in integration tests and which to replace with mocks. You will also see integration testing best practices that will help improve the health of your code base in general: making domain model boundaries explicit, reducing the number of layers in the application, and eliminating circular dependencies. Finally, you’ll learn why interfaces with a single implementation should be used sporadically, and how and when to test logging functionality.

8.1 什么是集成测试?

8.1  What is an integration test?

集成测试在您的测试套件中扮演着重要的角色。平衡单元测试和集成测试的数量也很重要。您很快就会看到这个角色是什么以及如何保持平衡,但首先,让我回顾一下集成测试与单元测试的区别。

Integration tests play an important role in your test suite. It’s also crucial to balance the number of unit and integration tests. You will see shortly what that role is and how to maintain the balance, but first, let me give you a refresher on what differentiates an integration test from a unit test.

8.1.1 集成测试的作用

8.1.1  The role of integration tests

您可能还记得第 2 章,单元测试是满足以下三个要求的测试:

As you may remember from chapter 2, a unit test is a test that meets the following three requirements:

  • 验证单个行为单元

  • Verifies a single unit of behavior

  • 做的很快

  • Does it quickly

  • 并与其他测试隔离

  • And in isolation from other tests

不满足这三个要求中的至少一个要求的测试属于集成测试类别。那么集成测试就是任何不是单元测试的测试。

A test that doesn’t meet at least one of these three requirements falls into the category of integration tests. An integration test then is any test that is not a unit test.

实际上,集成测试几乎总是验证您的系统如何与进程外依赖项集成。换句话说,这些测试涵盖了控制器象限的代码(有关代码象限的更多详细信息,请参阅第 7 章)。图8.1中的图表显示了单元测试和集成测试的典型职责。单元测试涵盖域模型,而集成测试检查将域模型与进程外依赖项粘合在一起的代码。

In practice, integration tests almost always verify how your system works in integration with out-of-process dependencies. In other words, these tests cover the code from the controllers quadrant (see chapter 7 for more details about code quadrants). The diagram in figure 8.1 shows the typical responsibilities of unit and integration tests. Unit tests cover the domain model, while integration tests check the code that glues that domain model with out-of-process dependencies.

图 8.1。集成测试涵盖控制器,而单元测试涵盖领域模型和算法。根本不应该测试琐碎和过于复杂的代码。

Figure 8.1. Integration tests cover controllers, while unit tests cover the domain model and algorithms. Trivial and overcomplicated code shouldn’t be tested at all.

CH08 图1 int测试作用

请注意,覆盖控制器象限的测试有时也可以是单元测试。如果所有进程外依赖项都被模拟替换,测试之间将不会共享依赖项,这将使这些测试保持快速并保持彼此隔离。大多数应用程序确实具有进程外依赖性,但无法用模拟替换。这通常是一个数据库——一种对其他应用程序不可见的依赖项。

Note that tests covering the controllers quadrant can sometimes be unit tests too. If all out-of-process dependencies are replaced with mocks, there will be no dependencies shared between tests, which would allow those tests to remain fast and maintain their isolation from each other. Most applications do have an out-of-process dependency that can’t be replaced with a mock though. That’s usually a database—a dependency that is not visible to other applications.

您可能还记得第 7 章,图8.1中的其他两个象限(简单代码和过于复杂的代码)根本不应该进行测试。琐碎的代码不值得付出努力,而过于复杂的代码应该重构为算法和控制器。因此,您的所有测试都必须专门关注域模型和控制器象限。

As you may also remember from chapter 7, the other two quadrants from figure 8.1 (trivial code and overcomplicated code) shouldn’t be tested at all. Trivial code isn’t worth the effort, while overcomplicated code should be refactored into algorithms and controllers. Thus, all your tests must focus on the domain model and the controllers quadrants exclusively.

8.1.2 重访测试金字塔

8.1.2  The test pyramid revisited

在单元测试和集成测试之间保持平衡很重要。直接使用进程外依赖项会使集成测试变慢。此类测试的维护成本也更高。维护成本的增加是由于

It’s important to maintain a balance between unit and integration tests. Working directly with out-of-process dependencies makes integration tests slow. Such tests are also more expensive to maintain. The increase in maintainability costs is due to

  • 保持进程外依赖性可操作的必要性

  • The necessity to keep the out-of-process dependencies operational

  • 涉及的协作者数量更多,这会增加测试的规模

  • The greater number of collaborators involved, which inflates the test’s size

另一方面,集成测试会处理大量代码(包括您的代码和应用程​​序使用的库中的代码),这使得它们在防止回归方面比单元测试更好。它们也更脱离生产代码,因此对重构有更好的抵抗力。

On the other hand, integration tests go through a larger amount of code (both your code and the code in the libraries used by the application), which makes them better than unit tests at protecting against regressions. They are also more detached from the production code and therefore have better resistance to refactoring.

单元测试和集成测试之间的比例可能因项目的具体情况而异,但一般的经验法则如下。

The ratio between unit and integration tests can differ depending on the project’s specifics, but the general rule of thumb is the following.

 

 

[提示] 提示

通过单元测试尽可能多地检查业务场景的边缘情况。使用集成测试来覆盖一条快乐的道路,以及单元测试无法覆盖的任何边缘情况。

Check as many of the business scenario’s edge cases as possible with unit tests. Use integration tests to cover one happy path, as well as any edge cases that can’t be covered by unit tests.

将大部分工作负载转移到单元测试有助于保持较低的维护成本。同时,对每个业务场景进行一到两次总体集成测试可确保整个系统的正确性。

Shifting the majority of the workload to unit tests helps keep maintenance costs low. At the same time, having one or two overarching integration tests per business scenario ensures the correctness of your system as a whole.

定义 8.1:

Definition 8.1:

快乐之路是业务场景的成功执行。边缘情况是业务场景执行导致错误。

A happy path is a successful execution of a business scenario. An edge case is when the business scenario execution results in an error.

该指南形成了单元测试和集成测试之间的金字塔状比例,如图8.2所示(如第 2 章所述,端到端测试是集成测试的一个子集)。

This guideline forms the pyramid-like ratio between unit and integration tests as shown in figure 8.2 (as discussed in chapter 2, end-to-end tests are a subset of integration tests).

图 8.2。测试金字塔代表最适合大多数应用程序的权衡。快速且廉价的单元测试涵盖了大多数边缘情况,而少量缓慢且昂贵的集成测试确保了系统整体的正确性。

Figure 8.2. The test pyramid represents a trade-off that works best for most applications. Fast and cheap unit tests cover the majority of edge cases, while a smaller number of slow and more expensive integration tests ensure the correctness of the system as a whole.

CH08 图2 测试金字塔

根据项目的复杂性,测试金字塔可以采用不同的形状。简单的应用程序在领域模型和算法象限中几乎没有(如果有的话)代码。结果,测试形成了一个矩形而不是金字塔,具有相同数量的单元和集成测试(图8.3)。在最微不足道的情况下,您可能没有任何单元测试。

The test pyramid can take different shapes depending on the project’s complexity. Simple applications have little (if any) code in the domain model and algorithms quadrant. As a result, tests form a rectangle instead of a pyramid, with an equal number of unit and integration tests (figure 8.3). In the most trivial cases, you might have no unit tests whatsoever.

图 8.3。一个简单项目的测试金字塔。与普通金字塔相比,复杂性小需要较少数量的单元测试。

Figure 8.3. The test pyramid of a simple project. Little complexity requires a smaller number of unit tests compared to a normal pyramid.

CH08 图3 测试金字塔2

请注意,集成测试即使在简单的应用程序中也能保持其价值。验证您的代码如何与其他子系统集成仍然很重要,无论它多么简单。

Note that integration tests retain their value even in simple applications. It’s still important to verify how your code, however simple it is, works in integration with other subsystems.

8.1.3 集成测试与快速失败

8.1.3  Integration testing versus failing fast

本节详细说明了使用集成测试覆盖每个业务场景的一条快乐路径以及单元测试无法覆盖的任何边缘案例的指南。

This section elaborates on the guideline of using integration tests to cover one happy path per business scenario and any edge cases that can’t be covered by unit tests.

对于集成测试,选择最长的快乐路径以验证与所有进程外依赖项的交互。如果没有一条路径可以通过所有此类交互,请编写额外的集成测试——尽可能多地捕获与每个外部系统的通信。

For an integration test, select the longest happy path in order to verify interactions with all out-of-process dependencies. If there’s no one path that goes through all such interactions, write additional integration tests—as many as needed to capture communications with every external system.

至于单元测试无法涵盖的边缘情况,指南的这一部分也有例外。如果边缘案例的不正确执行会立即导致整个应用程序失败,则无需测试该边缘案例。例如,您在第 7 章中看到User示例 CRM 系统如何实施一个CanChangeEmail方法并将其成功执行作为ChangeEmail()(清单8.1)的先决条件。

As for the edge cases that can’t be covered by unit tests, there are exceptions to this part of the guideline too. There’s no need to test an edge case if an incorrect execution of that edge case immediately fails the entire application. For example, you saw in chapter 7 how User from the sample CRM system implemented a CanChangeEmail method and made its successful execution a precondition for ChangeEmail() (listing 8.1).

清单 8.1。班级User_

Listing 8.1. The User class

public void ChangeEmail(string newEmail, Company 公司)
{
    Precondition.Requires(CanChangeEmail() == null);

    /* 方法的其余部分 */
}
public void ChangeEmail(string newEmail, Company company)
{
    Precondition.Requires(CanChangeEmail() == null);

    /* the rest of the method */
}

如果该方法返回错误,控制器将调用CanChangeEmail()并中断操作,如以下清单所示。

The controller invokes CanChangeEmail() and interrupts the operation if that method returns an error as shown in the following listing.

清单 8.2。检查是否可以更改电子邮件

Listing 8.2. Checking if the email can be changed

// 用户控制器
public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    用户 user = UserFactory.Create(userData);

    字符串错误 = user.CanChangeEmail();
    如果(错误!= null)   
        返回错误;    

    /* 方法的其余部分 */
}
// UserController
public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    User user = UserFactory.Create(userData);

    string error = user.CanChangeEmail();
    if (error != null)   
        return error;    

    /* the rest of the method */
}

边缘案例

The edge case

清单8.2显示了理论上可以用集成测试涵盖的边缘情况。但是,这样的测试并没有提供足够重要的价值。如果控制器在未事先咨询的情况下尝试更改电子邮件CanChangeEmail(),则应用程序会崩溃。此错误会在第一次执行时暴露出来,因此很容易被发现和修复。它也不会导致数据损坏。

Listing 8.2 shows the edge case that you could theoretically cover with an integration test. Such a test doesn’t provide a significant enough value though. If the controller tries to change the email without consulting with CanChangeEmail() first, the application crashes. This bug reveals itself with the first execution and thus is easy to notice and fix. It also doesn’t lead to data corruption.

 

 

[提示] 提示

没有测试总比糟糕的测试好。不提供重要价值的测试是糟糕的测试。

No tests are better than bad tests. A test that doesn’t provide significant value is a bad test.

与控制器对 的调用不同,测试CanChangeEmail()前提条件本身的存在。但这最好通过单元测试来完成;不需要集成测试。User

Unlike the call from the controller to CanChangeEmail(), the presence of the precondition itself in User should be tested. But that is better done with a unit test; there’s no need for an integration test.

让错误快速显现出来被称为Fail Fast principle,它是集成测试的可行替代方案。

Making bugs manifest themselves quickly is called the Fail Fast principle, and it’s a viable alternative to integration testing.

快速失败原则

The Fail Fast principle

Fail Fast 原则代表在发生任何意外错误时立即停止当前操作。此原则通过以下方式使您的应用程序更加稳定:

The Fail Fast principle stands for stopping the current operation as soon as any unexpected error occurs. This principle makes your application more stable by:

  • 缩短反馈回路。--越早发现错误,修复起来就越容易。与在开发过程中发现的错误相比,修复已经在生产中的错误的成本要高出几个数量级。

  • Shortening the feedback loop.--The sooner you detect a bug, the easier it is to fix. A bug that is already in production is orders of magnitude more expensive to fix compared to a bug found during development.

  • 保护持久状态。--错误导致应用程序的状态损坏。一旦该状态渗透到数据库中,修复起来就会变得更加困难。快速失败有助于防止腐败蔓延。

  • Protecting the persistence state.--Bugs lead to the application’s state corruption. Once that state penetrates into the database, it becomes much harder to fix. Failing fast helps you prevent the corruption from spreading.

停止当前操作通常通过抛出异常来完成,因为异常具有完全适合 Fail Fast 原则的语义:它们中断程序流并弹出到执行堆栈的最高级别,您可以在其中记录它们并关闭或重新启动操作。

Stopping the current operation is normally done by throwing exceptions because exceptions have the semantics perfectly suitable for the Fail Fast principle: they interrupt the program flow and pop up to the highest level of the execution stack, where you can log them and shut down or restart the operation.

先决条件是 Fail Fast 原则的一个例子。失败的先决条件表示对应用程序状态做出的错误假设,这始终是一个错误。另一个例子是从配置文件中读取数据。您可以安排读取逻辑,使其在配置文件中的数据不完整或不正确时抛出异常。您还可以将此逻辑放在靠近应用程序启动的位置,这样,如果应用程序的配置出现问题,它就不会启动。

Preconditions are one example of the Fail Fast principle in action. A failing precondition signifies an incorrect assumption made about the application state, which is always a bug. Another example is reading data from a configuration file. You can arrange the reading logic such that it would throw an exception if the data in the configuration file is incomplete or incorrect. You can also put this logic close to the application startup, so that the application doesn’t launch if there’s a problem with its configuration.

8.2 直接测试哪些进程外依赖

8.2  Which out-of-process dependencies to test directly

正如我之前提到的,集成测试验证您的系统如何与进程外依赖项集成。有两种方法可以实现这种验证:使用真正的进程外依赖或用模拟替换该依赖。本节说明何时应用这两种方法中的每一种。

As I mentioned earlier, integration tests verify how your system integrates with out-of-process dependencies. There are two ways to implement such verification: use the real out-of-process dependency or replace that dependency with a mock. This section shows when to apply each of the two approaches.

8.2.1 两种进程外依赖

8.2.1  The two types of out-of-process dependencies

所有进程外依赖项都分为两类:

All out-of-process dependencies fall into two categories:

  • 托管依赖项(您可以完全控制的进程外依赖项)。这些依赖项只能通过您的应用程序访问;与他们的互动对外部世界是不可见的。一个典型的例子是数据库。外部系统通常不直接访问您的数据库;他们通过您的应用程序提供的 API 来做到这一点。

  • Managed dependencies (out-of-process dependencies you have full control over). These dependencies are only accessible through your application; interactions with them aren’t visible to the external world. A typical example is a database. External systems normally don’t access your database directly; they do that through the API your application provides.

  • 非托管依赖项(您无法完全控制的进程外依赖项)。与此类依赖项的交互可以从外部观察到的。示例包括 SMTP 服务器和消息总线:两者都会产生对其他应用程序可见的副作用。

  • Unmanaged dependencies (out-of-process dependencies you don’t have full control over). Interactions with such dependencies are observable externally. Examples include an SMTP server and a message bus: both produce side effects visible to other applications.

我在第 5 章中提到,与托管依赖项的通信是实现细节。相反,与非托管依赖项的通信是系统可观察行为的一部分(图8.4)。这种区别导致集成测试中进程外依赖的处理方式不同。

I mentioned in chapter 5 that communications with managed dependencies are implementation details. Conversely, communications with unmanaged dependencies are part of your system’s observable behavior (figure 8.4). This distinction leads to the difference in treatment of out-of-process dependencies in integration tests.

V03 使用托管依赖的真实实例;用模拟替换非托管依赖项。

V03Use real instances of managed dependencies; replace unmanaged dependencies with mocks.

图 8.4。与托管依赖项的通信是实现细节;使用集成测试中的依赖项。与非托管依赖项的通信是系统可观察行为的一部分。应该模拟这种依赖关系。

Figure 8.4. Communications with managed dependencies are implementation details; use such dependencies as is in integration tests. Communications with unmanaged dependencies are part of your system’s observable behavior. Such dependencies should be mocked out.

CH08 图 4 outofproc deps

正如第 5 章中所讨论的,保留具有非托管依赖项的通信模式的要求源于维护与这些依赖项的向后兼容性的必要性。模拟非常适合这项任务。使用模拟,您可以根据任何可能的重构确保通信模式的持久性。

As discussed in chapter 5, the requirement to preserve the communication pattern with unmanaged dependencies stems from the necessity to maintain backward compatibility with those dependencies. Mocks are perfect for this task. With mocks, you can ensure the communication pattern permanence in light of any possible refactorings.

但是,没有必要在与托管依赖项的通信中保持向后兼容性,因为您的应用程序是唯一与它们对话的应用程序。外部客户不关心你如何组织你的数据库。唯一重要的是系统的最终状态。在集成测试中使用托管依赖项的真实实例可以帮助您从外部客户端的角度验证最终状态。它还有助于数据库重构,例如重命名列,甚至从一个数据库迁移到另一个数据库。

However, there’s no need to maintain backward compatibility in communications with managed dependencies because your application is the only one who talks to them. External clients don’t care how you organize your database. The only thing that matters is the final state of your system. Using real instances of managed dependencies in integration tests helps you verify that final state from the external client’s point of view. It also helps during database refactorings, such as renaming a column or even migrating from one database to another.

8.2.2 使用托管和非托管依赖项

8.2.2  Working with both managed and unmanaged dependencies

有时,您会遇到进程外依赖性,它同时表现出托管和非托管依赖性的属性。一个很好的例子是其他应用程序可以访问的数据库。

Sometimes, you’ll encounter an out-of-process dependency that exhibits attributes of both managed and unmanaged dependencies. A good example is a database that other applications have access to.

故事通常是这样的。一个系统从它自己的专用数据库开始。过了一会儿,另一个系统开始需要来自同一个数据库的数据。因此,该团队决定共享对有限数量表的访问权限,以便于与其他系统集成。因此,数据库成为托管和非托管的依赖项。它仍然包含仅对您的应用程序可见的部分,但是,除了这些部分之外,它还有许多其他应用程序可以访问的表。

The story usually goes like this. A system begins with its own dedicated database. After awhile, another system beings to require data from the same database. And so the team decides to share access to a limited number of tables just for the ease of integration with that other system. As a result, the database becomes a dependency that is both managed and unmanaged. It still contains parts that are visible to your application only, but, in addition to those parts, it also has a number of tables accessible by other applications.

使用数据库是实现系统间集成的一种糟糕方式,因为它将这些系统相互耦合并使它们的进一步开发复杂化。只有在所有其他选项都用尽时才采用这种方法。进行集成的更好方法是通过 API(用于同步通信)或消息总线(用于异步通信)。

The use of a database is a poor way to implement integration between systems because it couples these systems to each other and complicates their further development. Only resort to this approach when all other options are exhausted. A better way to do the integration is via an API (for synchronous communications) or a message bus (for asynchronous communications).

但是,当您已经拥有一个共享数据库并且在可预见的未来无法对其进行任何操作时,您会怎么做?在这种情况下,将对其他应用程序可见的表视为非托管依赖项。这些表实际上充当消息总线,其行扮演消息的角色。使用模拟来确保与这些表的通信模式保持不变。同时,将数据库的其余部分视为托管依赖项并验证其最终状态,而不是与它的交互(图8.5)。

But what do you do when you already have a shared database and can’t do anything about it in the foreseeable future? In this case, treat tables that are visible to other applications as an unmanaged dependency. These tables in effect act as a message bus with its rows playing the role of messages. Use mocks to make sure that the communication pattern with these tables remains unchanged. At the same time, treat the rest of your database as a managed dependency and verify its final state, not the interactions with it (figure 8.5).

图 8.5。将外部应用程序可见的数据库部分视为非托管依赖项。在集成测试中用模拟替换它。将数据库的其余部分视为托管依赖项。验证其最终状态,而不是与其交互。

Figure 8.5. Treat the part of the database that is visible to external applications as an unmanaged dependency. Replace it with mocks in integration tests. Treat the rest of the database as a managed dependency. Verify its final state, not interactions with it.

CH08 图5 非托管数据库

区分数据库的这两个部分很重要,因为共享表在外部是可见的,您需要注意应用程序如何与它们通信。除非绝对必要,否则不要更改系统与这些表交互的方式!您永远不知道其他应用程序将如何应对这种变化。

It’s important to differentiate these two parts of your database because, again, the shared tables are observable externally, and you need to be careful about how your application communicates with them. Don’t change the way your system interacts with those tables unless absolutely necessary! You never know how other applications will react to such a change.

8.2.3 如果不能在集成测试中使用真实数据库怎么办?

8.2.3  What if you can’t use a real database in integration tests?

有时,出于您无法控制的原因,您无法在集成测试中使用托管依赖项的真实版本。一个例子是遗留数据库,由于某些 IT 安全策略,您无法将其部署到测试自动化环境,更不用说开发人员机器了。或者,因为设置和维护测试数据库实例的成本过高。

Sometimes, for reasons outside of your control, you just can’t use a real version of a managed dependency in integration tests. An example would be a legacy database that you can’t deploy to a test automation environment, not to say a developer machine, because of some IT security policy. Or, because the cost of setting up and maintaining a test database instance is prohibitive.

在这种情况下你应该怎么做?你是否应该模拟数据库,尽管它是一个托管依赖?不,因为模拟托管依赖会损害集成测试对重构的抵抗力。此外,此类测试也不再提供针对回归的良好保护。如果数据库是项目中唯一的进程外依赖项,那么与现有的单元测试集相比,生成的集成测试将不会提供额外的保护(假设这些单元测试遵循第 7 章的指导方针)。

What should you do in such a situation? Should you mock the database out anyway, despite it being a managed dependency? No, because mocking out a managed dependency compromises the integration tests' resistance to refactoring. Furthermore, such tests no longer provide as good protection against regressions either. And if the database is the only out-of-process dependency in your project, the resulting integration tests would deliver no additional protection compared to the already existing set of unit tests (assuming these unit tests follow the guidelines from chapter 7).

除了单元测试之外,此类集成测试唯一要做的就是检查控制器调用了哪些存储库方法。换句话说,除了控制器中的那三行代码是正确的之外,您并没有真正获得任何信心,同时仍然需要做大量的管道工作。

The only thing such integration tests would do, in addition to unit tests, is check what repository methods the controller calls. In other words, you don’t really gain confidence of anything other than those three lines of code in your controller are correct, while still having to do a lot of plumbing.

如果您不能按原样测试数据库,则根本不要编写集成测试,而是专注于域模型的单元测试。请记住始终对所有测试进行严密审查。不能提供足够高价值的测试不应出现在您的测试套件中。

If you can’t test the database as is, don’t write integration tests at all and instead focus exclusively on unit testing of the domain model. Remember to always put all your tests under close scrutiny. Tests that don’t provide a high enough value should have no place in your test suite.

8.3 集成测试:一个例子

8.3  Integration testing: An example

让我们回到第 7 章的示例 CRM 系统,看看如何用集成测试覆盖它。您可能还记得,该系统实现了一项功能:更改用户的电子邮件。它从数据库中检索用户和公司,将决策委托给域模型,然后将结果保存回数据库,并在需要时将消息发送到总线上(图 8.6

Let’s get back to the sample CRM system from chapter 7 and see how it can be covered with integration tests. As you may recall, this system implements one feature: changing the user’s email. It retrieves the user and the company from the database, delegates the decision-making to the domain model, then saves the results back to the database and puts a message on the bus if needed (figure 8.6).

图 8.6。更改用户电子邮件的用例。控制器协调数据库、消息总线和域模型之间的工作。

Figure 8.6. The use case of changing the user’s email. The controller orchestrates the work between the database, the message bus, and the domain model.

CH08 图6 CRM

清单6.8显示了控制器当前的外观。

Listing 6.8 shows how the controller currently looks.

清单 8.3。用户控制器

Listing 8.3. The user controller

公共类用户控制器
{
    私有只读数据库_database = new Database();
    private readonly MessageBus _messageBus = new MessageBus();

    public string ChangeEmail(int userId, string newEmail)
    {
        object[] userData = _database.GetUserById(userId);
        用户 user = UserFactory.Create(userData);

        字符串错误 = user.CanChangeEmail();
        如果(错误!= null)
            返回错误;

        对象 [] 公司数据 = _database.GetCompany();
        公司公司 = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _database.SaveCompany(公司);
        _database.SaveUser(用户);
        foreach(user.EmailChangedEvents 中的 EmailChangedEvent ev)
        {
            _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
        }

        返回“确定”;
    }
}
public class UserController
{
    private readonly Database _database = new Database();
    private readonly MessageBus _messageBus = new MessageBus();

    public string ChangeEmail(int userId, string newEmail)
    {
        object[] userData = _database.GetUserById(userId);
        User user = UserFactory.Create(userData);

        string error = user.CanChangeEmail();
        if (error != null)
            return error;

        object[] companyData = _database.GetCompany();
        Company company = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _database.SaveCompany(company);
        _database.SaveUser(user);
        foreach (EmailChangedEvent ev in user.EmailChangedEvents)
        {
            _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);
        }

        return "OK";
    }
}

在下一节中,我将首先概述使​​用集成测试进行验证的场景。然后我将向您展示如何在测试中使用数据库和消息总线。

In the following section, I’ll first outline scenarios to verify using integration tests. Then I’ll show you how to work with the database and the message bus in tests.

8.3.1 测试什么场景?

8.3.1  What scenarios to test?

正如我之前提到的,集成测试的一般准则是覆盖最长的快乐路径和单元测试无法执行的任何边缘情况。最长的快乐路径是经过所有进程外依赖项的路径。

As I mentioned earlier, the general guideline for integration testing is to cover the longest happy path and any edge cases that can’t be exercised by unit tests. The longest happy path is the one that goes through all out-of-process dependencies.

在 CRM 项目中,最长的快乐之路是从公司电子邮件到非公司电子邮件的更改。这种变化会导致最大数量的副作用:

In the CRM project, the longest happy path is a change from a corporate to a non-corporate email. Such a change leads to the maximum number of side effects:

  • 在数据库中,用户和公司都更新了:用户更改了他们的类型(从公司到非公司)和电子邮件,公司更改了员工人数。

  • In the database, both the user and the company are updated: the user changes their type (from corporate to non-corporate) and email, and the company changes its number of employees.

  • 消息被发送到消息总线。

  • A message is sent to the message bus.

至于未通过单元测试测试的边缘情况,只有一种这样的边缘情况:无法更改电子邮件的场景。不过,没有必要测试这种情况,因为如果控制器中不存在这种检查,应用程序将快速失败。这给我们留下了一个单一的集成测试:

As for the edge cases that aren’t tested by unit tests, there’s only one such edge case: the scenario where the email can’t be changed. There’s no need to test this scenario, though, because the application will fail-fast if this check isn’t present in the controller. That leaves us with a single integration test:

public void Changing_email_from_corporate_to_non_corporate()
public void Changing_email_from_corporate_to_non_corporate()

8.3.2 数据库和消息总线的分类

8.3.2  Categorizing the database and the message bus

在编写集成测试之前,您需要对这两个进程外依赖进行分类,并决定其中哪些直接测试,哪些用 mock 替换。

Before writing the integration test, you need to categorize the two out-of-process dependencies and decide which of them to test directly and which to replace with a mock.

应用程序数据库是托管依赖项,因为没有其他系统可以访问它。因此,您应该使用它的真实实例。集成测试将

The application database is a managed dependency because no other system can access it. Therefore, you should use a real instance of it. The integration test will

  • 将用户和公司插入数据库。

  • Insert a user and a company into the database.

  • 在该数据库上运行电子邮件方案的更改。

  • Run the change of email scenario on that database.

  • 验证数据库状态。

  • Verify the database state.

另一方面,消息总线是一种不受管理的依赖项——它的唯一目的是实现与其他系统的通信。集成测试将模拟消息总线,然后验证控制器和模拟之间的交互。

On the other hand, the message bus is an unmanaged dependency—its sole purpose is to enable communication with other systems. The integration test will mock out the message bus, and verify the interactions between the controller and the mock afterwards.

8.3.3 端到端测试呢?

8.3.3  What about end-to-end testing?

我们的示例项目中不会有端到端测试。具有 API 的场景中的端到端测试将是针对该 API 的已部署和功能齐全的版本运行的测试,这意味着没有针对任何进程外依赖项的模拟(图 8.7 。相反,集成测试在同一进程中托管应用程序,并用模拟替换非托管依赖项(图8.8)。

There will be no end-to-end tests in our sample project. An end-to-end test in a scenario with an API would be a test running against a deployed and fully functioning version of that API, which means no mocks for any of the out-of-process dependencies (figure 8.7). On the contrary, integration tests host the application within the same process and substitute unmanaged dependencies with mocks (figure 8.8).

图 8.7。端到端测试模拟外部客户端,因此测试已部署的应用程序版本,所有进程外依赖项都包含在测试范围内。端到端测试不应直接检查托管依赖项(如数据库),只能通过应用程序间接检查。

Figure 8.7. End-to-end tests emulate the external client and therefore test a deployed version of the application with all out-of-process dependencies included in the testing scope. End-to-end tests shouldn’t check managed dependencies (such as the database) directly, only indirectly through the application.

CH08 FIG 7首尾相接

图 8.8。集成测试在同一进程中托管应用程序。与端到端测试不同,集成测试用模拟代替非托管依赖。集成测试的唯一进程外组件是托管依赖项。

Figure 8.8. Integration tests host the application within the same process. Unlike end-to-end tests, integration tests substitute unmanaged dependencies with mocks. The only out-of-process component for integration tests are managed dependencies.

CH08 图8整合

正如我在第 2 章中提到的,是否使用端到端测试是一个判断调用。在大多数情况下,当您在集成测试范围内包含托管依赖项并仅模拟非托管依赖项时,集成测试提供的保护级别足以接近端到端测试,因此您可以跳过端到端测试- 完全结束测试。尽管您仍然可以创建一两个总体端到端测试,以在部署后对项目进行健全”检查。让这样的测试也通过最长的快乐路径,以确保您的应用程序与所有进程外依赖项正确通信。要模拟外部客户端的行为,请直接检查消息总线,但要通过应用程序本身验证数据库的状态。

As I mentioned in chapter 2, whether to use end-to-end tests is a judgment call. For the most part, when you include managed dependencies in the integration testing scope and mock out only unmanaged dependencies, integration tests provide a level of protection that is close enough to that of end-to-end tests, so you can skip end-to-end testing altogether. Although you could still create one or two overarching end-to-end tests that would sanity check the project after deployment. Make such tests go through the longest happy path too, to ensure your application communicates with all out-of-process dependencies properly. To emulate the external client’s behavior, check the message bus directly but verify the database’s state through the application itself.

8.3.4 集成测试:第一次尝试

8.3.4  Integration testing: The first try

清单8.4显示了集成测试的第一个版本的外观。

Listing 8.4 shows how the first version of the integration test looks.

清单 8.4。集成测试

Listing 8.4. The integration test

[事实]
public void Changing_email_from_corporate_to_non_corporate()
{
    // 安排
    var db = new Database(ConnectionString);         
    用户user = CreateUser(                           
        "user@mycorp.com", UserType.Employee, db);    
    CreateCompany("mycorp.com", 1, db);              

    var messageBusMock = new Mock<IMessageBus>();    
    var sut = new UserController(db, messageBusMock.Object);

    // 行为
    字符串结果 = sut.ChangeEmail(user.UserId, "new@gmail.com");

    //断言
    Assert.Equal("OK", 结果);

    object[] userData = db.GetUserById(user.UserId);   
    用户 userFromDb = UserFactory.Create(userData);     
    Assert.Equal("new@gmail.com", userFromDb.Email);    
    Assert.Equal(UserType.Customer, userFromDb.Type);  

    对象 [] 公司数据 = db.GetCompany();            
    公司 companyFromDb = CompanyFactory              
        .创建(公司数据);                           
    Assert.Equal(0, companyFromDb.NumberOfEmployees);  

    messageBusMock.Verify(                    
        x => x.SendEmailChangedMessage(       
            user.UserId, "new@gmail.com"),   
        次.一次);                          
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    // Arrange
    var db = new Database(ConnectionString);         
    User user = CreateUser(                          
        "user@mycorp.com", UserType.Employee, db);   
    CreateCompany("mycorp.com", 1, db);              

    var messageBusMock = new Mock<IMessageBus>();    
    var sut = new UserController(db, messageBusMock.Object);

    // Act
    string result = sut.ChangeEmail(user.UserId, "new@gmail.com");

    // Assert
    Assert.Equal("OK", result);

    object[] userData = db.GetUserById(user.UserId);   
    User userFromDb = UserFactory.Create(userData);    
    Assert.Equal("new@gmail.com", userFromDb.Email);   
    Assert.Equal(UserType.Customer, userFromDb.Type);  

    object[] companyData = db.GetCompany();            
    Company companyFromDb = CompanyFactory             
        .Create(companyData);                          
    Assert.Equal(0, companyFromDb.NumberOfEmployees);  

    messageBusMock.Verify(                   
        x => x.SendEmailChangedMessage(      
            user.UserId, "new@gmail.com"),   
        Times.Once);                         
}

数据库存储库

The database repository

在数据库中创建用户和公司

Creating the user and company in the database

为消息总线设置模拟

Setting up a mock for the message bus

断言用户的状态

Asserting the user’s state

断言公司的状态

Asserting the company’s state

检查与模拟的交互

Checking the interactions with the mock

 

 

[提示] 提示

请注意,在 arrange 部分中,测试不会自行将用户和公司插入数据库,而是调用 和CreateUserhelperCreateCompany方法。这些方法可以在多个集成测试中重复使用。

Notice that in the arrange section, the test doesn’t insert the user and the company into the database on its own but instead calls the CreateUser and CreateCompany helper methods. These methods can be reused across multiple integration tests.

独立于用作输入参数的数据检查数据库的状态很重要。为此,集成测试在断言部分分别查询用户和公司数据,创建新的userFromDbcompanyFromDb实例,然后才断言它们的状态。这种方法确保测试练习既写入数据库又从数据库读取,从而提供最大程度的保护以防止回归。读取本身必须使用控制器内部使用的相同代码来实现;在此特定示例中,使用DatabaseUserFactoryCompanyFactory类。

It’s important to check the state of the database independently of the data used as input parameters. To do that, the integration test queries the user and company data separately in the assert section, creates new userFromDb and companyFromDb instances, and only then asserts their state. This approach ensures that the test exercises both writes to and reads from the database and thus provides the maximum protection against regressions. The reading itself must be implemented using the same code the controller utilizes internally; in this particular example, using the Database, UserFactory, and CompanyFactory classes.

这个集成测试在完成工作的同时,仍然可以从一些改进中受益。例如,您也可以在断言部分使用辅助方法,以减少该部分的大小。此外,messageBusMock没有提供尽可能好的防止回归的保护。我们将在随后的两章讨论模拟和数据库测试最佳实践时讨论这些改进。

This integration test, while getting the job done, can still benefit from some improvement. For instance, you could use helper methods in the assertion section too, in order to reduce this section’s size. Also, messageBusMock doesn’t provide as good protection against regressions as it potentially could. We’ll talk about these improvements in the subsequent two chapters where we discuss mocking and database testing best practices.

8.4 使用接口抽象依赖

8.4  Using interfaces to abstract dependencies

单元测试领域中最容易被误解的主题之一是接口的使用。开发人员经常将无效的理由归咎于他们引入接口的原因,因此倾向于过度使用它们。在本节中,我将详细说明那些无效的原因,并展示在什么情况下使用接口是可取的,什么是不可取的。

One of the most misunderstood subjects in the sphere of unit testing is the use of interfaces. Developers often ascribe invalid reasons to why they introduce interfaces and, as a result, tend to overuse them. In this section, I’ll expand on those invalid reasons and show in what circumstances the use of interfaces is and isn’t preferable.

8.4.1 接口和松耦合

8.4.1  Interfaces and loose coupling

许多开发人员为进程外依赖项引入接口,例如数据库或消息总线,即使这些接口只有一种实现。如今,这种做法已经非常普遍,几乎没有人质疑它。您经常可以看到类似于以下的类接口对:

Many developers introduce interfaces for out-of-process dependencies, such as the database or the message bus, even when these interfaces have only one implementation. This practice has become so wide-spread nowadays that hardly anyone questions it. You can often see class-interface pairs similar to the following:

公共接口 IMessageBus
公共类消息总线:IMessageBus

公共接口 IUserRepository
公共类 UserRepository : IUserRepository
public interface IMessageBus
public class MessageBus : IMessageBus

public interface IUserRepository
public class UserRepository : IUserRepository

使用此类接口背后的常见原因是它们可以帮助您:

The common reasoning behind the use of such interfaces is that they help you to:

  • 抽象进程外依赖,从而实现松耦合

  • Abstract out-of-process dependencies, thus achieving loose coupling

  • 在不更改现有代码的情况下添加新功能,从而遵循 Open-Closed 原则。

  • Add new functionality without changing the existing code, thus adhering to the Open-Closed principle.

这两个原因都是误解。具有单一实现的接口不是抽象,并且与实现这些接口的具体类一样,不提供松耦合。真正的抽象是被发现的,而不是发明的。根据定义,发现发生在事后,此时抽象已经存在但尚未在代码中明确定义。因此,要使接口成为真正的抽象,它必须至少有两个实现。

Both of these reasons are misconceptions. Interfaces with a single implementation are not abstractions and don’t provide loose coupling any more than concrete classes that implement those interfaces. Genuine abstractions are discovered not invented. The discovery, by definition, takes place post factum, when the abstraction already exists but is not yet clearly defined in the code. Thus, for an interface to be a genuine abstraction, it must have at least two implementations.

第二个原因(在不更改现有代码的情况下添加新功能的能力)是一种误解,因为它违反了更基本的原则——YAGNI。YAGNI代表你不会需要它,并提倡不要将时间投入到现在不需要的功能上。您不应开发此功能,也不应修改现有代码以考虑将来出现此类功能。主要原因有二:

The second reason (the ability to add new functionality without changing the existing code) is a misconception because it violates a more foundational principle—YAGNI. YAGNI stands for You aren’t gonna need it and advocates against investing time into functionality that’s not needed right now. You shouldn’t develop this functionality nor should you modify your existing code to account for appearance of such functionality in the future. The two major reasons why are:

  • 机会成本。如果你把时间花在业务人员目前不需要的功能上,你就会把时间从他们现在确实需要的功能上转移开。而且,当业务人员最终需要开发的功能时,他们对它的看法很可能已经发生了变化,您仍然需要调整已经编写的代码。这样的活动是浪费的。当实际需要出现时,从头开始实现功能更为有益。

  • Opportunity cost. If you spend time on a feature business people don’t need at the moment, you steer that time away from features they do need right now. Moreover, when the business people finally come to require the developed functionality, their view on it will most likely have evolved, and you will still need to adjust the already written code. Such activity is wasteful. It’s more beneficial to implement the functionality from scratch when the actual need for it emerges.

  • 项目中的代码越少越好。引入代码以防万一而不是立即需要会不必要地增加代码库的拥有成本。最好将引入新功能的时间尽可能推迟到项目的后期阶段。

  • The less code in the project, the better. Introducing code just in case without an immediate need unnecessarily increases your code base’s cost of ownership. It’s better to postpone introducing new functionality to as late a stage of your project as possible.

 

 

[提示] 提示

编写代码是解决问题的一种昂贵方式。解决方案需要的代码越少,代码越简单越好。

Writing code is an expensive way to solve problems. The less code the solution requires and the simpler that code is, the better.

有一些 YAGNI 不适用的例外情况,但这种情况很少见。对于这些情况,请参阅我的文章开闭原则与 YAGNI ,网址为https://enterprisecraftsmanship.com/2016/11/28/ocp-vs-yagni/

There are exceptional cases where YAGNI doesn’t apply, but these are few and far between. For those cases, see my article, Open-Closed Principle versus YAGNI, at https://enterprisecraftsmanship.com/2016/11/28/ocp-vs-yagni/.

8.4.2 为什么要为进程外依赖使用接口?

8.4.2  Why use interfaces for out-of-process dependencies?

那么,假设每个接口都只有一个实现,为什么还要为进程外依赖使用接口呢?真正的原因更加实际和脚踏实地。它是为了启用模拟——就这么简单。如果没有接口,您就无法创建测试替身,因此无法验证被测系统与进程外依赖项之间的交互。

So, why use interfaces for out-of-process dependencies at all, assuming that each of those interfaces has only one implementation? The real reason is much more practical and down-to-earth. It’s to enable mocking—as simple as that. Without an interface, you can’t create a test double and thus can’t verify interactions between the system under test and the out-of-process dependency.

因此,不要为进程外依赖项引入接口,除非您需要模拟这些依赖项。您只模拟非托管依赖项,因此准则可以归结为:仅对非托管依赖项使用接口。仍然显式地将托管依赖项注入控制器,但为此使用具体类。

Therefore, don’t introduce interfaces for out-of-process dependencies unless you need to mock those dependencies out. You only mock out unmanaged dependencies, and so the guideline can be boiled down to this: use interfaces for unmanaged dependencies only. Still inject managed dependencies into the controller explicitly but use concrete classes for that.

请注意,无论您是否模拟它们,真正的抽象(具有多个实现的抽象)都可以用接口表示。但是,出于模拟以外的原因引入具有单一实现的接口是违反 YAGNI 的。

Note that genuine abstractions (abstractions that have more than one implementation) can be represented with interfaces regardless of whether you mock them out or not. Introducing an interface with a single implementation for reasons other than mocking is a violation of YAGNI, however.

您可能已经注意到,在清单8.4中,UserController现在通过构造函数显式地接受消息总线和数据库,但只有消息总线具有相应的接口。数据库是托管依赖项,因此不需要这样的接口。这是控制器:

And you might have noticed in listing 8.4 that UserController now accepts both the message bus and the database explicitly via the constructor, but only the message bus has a corresponding interface. The database is a managed dependency and thus doesn’t require such an interface. Here’s the controller:

公共类用户控制器
{
    私有只读数据库_database;   
    私有只读 IMessageBus _messageBus;   

    public UserController(Database 数据库, IMessageBus messageBus)
    {
        _database = 数据库;
        _messageBus = 消息总线;
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        /* 该方法使用 _database 和 _messageBus */
    }
}
public class UserController
{
    private readonly Database _database;   
    private readonly IMessageBus _messageBus;   

    public UserController(Database database, IMessageBus messageBus)
    {
        _database = database;
        _messageBus = messageBus;
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        /* the method uses _database and _messageBus */
    }
}

具体类

A concrete class

界面

The interface

 

 

[提示] 提示

您可以通过在该依赖项中使方法成为虚拟并将类本身用作模拟的基础来模拟依赖项而无需求助于接口。不过,这种方法不如使用接口的方法。我将在第 11 章中详细解释接口与基类的主题。

You can mock out a dependency without resorting to an interface by making methods in that dependency virtual and using the class itself as a base for the mock. This approach is inferior to the one with interfaces though. I explain more on this topic of interfaces versus base classes in chapter 11.

8.4.3 为进程内依赖使用接口

8.4.3  Using interfaces for in-process dependencies

有时您会看到代码库,其中接口不仅返回进程外依赖性,还返回进程内依赖性。例如

You can sometimes see code bases where interfaces back not only out-of-process dependencies but in-process dependencies as well. For example

公共接口 IUser
{
    int UserId { 得到; 放; }
    字符串电子邮件 { 得到; }
    字符串 CanChangeEmail();
    void ChangeEmail(string newEmail, Company 公司);
}

公共类用户:IUser
{
    /* ... */
}
public interface IUser
{
    int UserId { get; set; }
    string Email { get; }
    string CanChangeEmail();
    void ChangeEmail(string newEmail, Company company);
}

public class User : IUser
{
    /* ... */
}

假设IUser只有一个实现(并且此类特定接口总是只有一个实现),这是一个巨大的危险信号。就像进程外依赖一样,为域类引入具有单一实现的接口的唯一原因是启用模拟。但与进程外依赖不同的是,你永远不应该检查域类之间的交互,因为这会导致脆弱的测试:测试与实现细节耦合,因此在抵制重构的指标上失败(请参阅第 5 章了解更多关于模拟和测试脆弱性)。

Assuming that IUser has only one implementation (and such specific interfaces would always only have one implementation), this is a huge red flag. Just like with out-of-process dependencies, the only reason to introduce an interface with a single implementation for a domain class is to enable mocking. But unlike out-of-process dependencies, you should never check interactions between domain classes because that results in brittle tests: tests that couple to implementation details and thus fail on the metric of resisting to refactoring (see chapter 5 for more details about mocks and test fragility).

8.5 集成测试最佳实践

8.5  Integration testing best practices

有一些通用指南可以帮助您充分利用集成测试:

There are some general guidelines that can help you get the most out of your integration tests:

  • 使领域模型边界明确

  • Making domain model boundaries explicit

  • 减少应用程序中的层数

  • Reducing the number of layers in the application

  • 消除循环依赖

  • Eliminating circular dependencies

与往常一样,有利于测试的最佳实践通常也往往会改善代码库的健康状况。

As usual, best practices that are beneficial for tests also tend to improve the health of your code base in general.

8.5.1 明确领域模型边界

8.5.1  Making domain model boundaries explicit

尝试在代码库中始终为域模型保留一个明确且众所周知的位置。领域模型是关于您的项目要解决的问题的领域知识的集合。为域模型分配显式边界有助于您更好地可视化和推理您的代码部分。

Try to always have an explicit and well-known place for the domain model in your code base. The domain model is the collection of domain knowledge about the problem your project is meant to solve. Assigning the domain model an explicit boundary helps you better visualize and reason about that part of your code.

这种做法也有助于测试。正如我在本章前面提到的,单元测试针对域模型和算法,而集成测试针对控制器。域类和控制器之间的显式边界使得区分单元测试和集成测试之间的区别变得更加容易。

This practice also helps with testing. As I mentioned earlier in this chapter, unit tests target the domain model and algorithms, while integration tests target controllers. The explicit boundary between domain classes and controllers makes it easier to tell the difference between unit and integration tests.

边界本身可以采用单独程序集或命名空间的形式。只要所有领域逻辑都放在一个单独的、不同的保护伞下并且不分散在代码库中,细节就没有那么重要了。

The boundary itself can take the form of a separate assembly or a namespace. The particulars aren’t that important as long as all of the domain logic is put under a single, distinct umbrella and not scattered across the code base.

8.5.2 减少层数

8.5.2  Reducing the number of layers

大多数程序员自然而然地倾向于通过引入额外的间接层来抽象和概括代码。在典型的企业级应用程序中,您可以很容易地观察到几个这样的层(图8.9)。

Most programmers naturally gravitate towards abstracting and generalizing the code by introducing additional layers of indirection. In a typical enterprise-level application, you can easily observe several such layers (figure 8.9).

图 8.9。各种应用程序问题通常通过单独的间接层来解决。典型特征占据每一层的一小部分。

Figure 8.9. Various application concerns often get addressed by separate layers of indirection. A typical feature takes up a small portion of each layer.

CH08 图9层

在极端情况下,一个应用程序获得了太多的抽象层,以至于很难浏览代码库并理解即使是最简单的操作背后的逻辑。在某些时候,您只想找到手头问题的具体解决方案,而不是对该解决方案的真空概括。

In extreme cases, an application gets so many abstraction layers that it becomes too hard to navigate the code base and understand the logic behind even the simplest operations. At some point, you just want to get to the specific solution of the problem at hand, not some generalization of that solution in a vacuum.

 

计算机科学中的所有问题都可以通过另一层间接来解决,除了间接层太多的问题。

All problems in computer science can be solved by another layer of indirection, except for the problem of too many layers of indirection.

 
  ——大卫·J·惠勒

间接层会对您推理代码的能力产生负面影响。当每个特征在每个层中都有表示时,您必须花费大量精力将所有部分组装成一个有凝聚力的图片。这造成了额外的精神负担,阻碍了整个开发过程。

Layers of indirection negatively affect your ability to reason about the code. When every feature has a representation in each of those layers, you have to put significant effort assembling all the pieces together into a cohesive picture. This creates an additional mental burden that handicaps the entire development process.

过多的抽象也无助于单元或集成测试。具有许多间接层的代码库往往在控制器和领域模型之间没有明确的界限(你可能还记得第 7 章,这是有效测试的先决条件)。还有一种更强烈的趋势是分别验证每一层。这种趋势导致许多低价值的集成测试,每个集成测试仅使用特定层的代码并模拟下面的层。最终结果总是一样的:对回归的保护不足,对重构的抵抗力低。

An excessive number of abstractions doesn’t help unit or integration testing either. Code bases with many layers of indirections tend not to have a clear boundary between controllers and the domain model (which, as you might remember from chapter 7, is a precondition for effective tests). There’s also a much stronger tendency to verify each layer separately. This tendency results in a lot of low value integration tests, each of which exercises only the code from a specific layer and mocks out layers underneath. The end result is always the same: insufficient protection against regressions combined with low resistance to refactoring.

尽量使用尽可能少的间接层。在大多数后端系统中,您可以只使用其中的三个:领域模型、应用程序服务层(控制器)和基础设施层。基础设施层通常包含不属于域模型的算法,以及允许访问进程外依赖项的代码(图8.10)。

Try to have as few layers of indirection as possible. In most backend systems, you can get away with just three of them: the domain model, application services layer (controllers), and infrastructure layer. The infrastructure layer typically consists of algorithms that don’t belong to the domain model, as well as code that enables access to out-of-process dependencies (figure 8.10).

图 8.10。您只需三层即可摆脱困境:领域层(包含领域逻辑)、应用程序服务层(为外部客户端提供入口点、协调领域类和进程外依赖项之间的工作)和基础设施层(与进程外依赖项一起工作;数据库存储库、ORM 映射和 SMTP 网关驻留在这一层)。

Figure 8.10. You can get away with just three layers: the domain layer (contains domain logic), application services layers (provides an entry point for the external client, coordinates the work between domain classes and out-of-process dependencies), and infrastructure layer (works with out-of-process dependencies; database repositories, ORM mappings, and SMTP gateways reside in this layer).

CH08 图10层

8.5.3 消除循环依赖

8.5.3  Eliminating circular dependencies

另一种可以大大提高代码库可维护性并使测试更容易的做法是消除循环依赖。

Another practice that can drastically improve maintainability of your code base and make testing easier is eliminating circular dependencies.

定义 8.2:

Definition 8.2:

循环依赖(也称为循环依赖)是两个或多个直接或间接相互依赖以正常运行的类。

A circular dependency (also known as cyclic dependency) is two or more classes that directly or indirectly depend on each other to function properly.

循环依赖的典型示例是回调(如以下清单所示)。

A typical example of a circular dependency is a callback (shown in the following listing).

清单 8.5。两个类相互依赖

Listing 8.5. Two classes depending on each other

公共类 CheckOutService
{
    public void CheckOut(int orderId)
    {
        var service = new ReportGenerationService();
        service.GenerateReport(orderId, this);

        /* 其他代码 */
    }
}

公共类 ReportGenerationService
{
    公共无效生成报告(
        订单号,
        签出服务签出服务)
    {
        /* 生成完成后调用 checkOutService */
    }
}
public class CheckOutService
{
    public void CheckOut(int orderId)
    {
        var service = new ReportGenerationService();
        service.GenerateReport(orderId, this);

        /* other code */
    }
}

public class ReportGenerationService
{
    public void GenerateReport(
        int orderId,
        CheckOutService checkOutService)
    {
        /* calls checkOutService when generation is completed */
    }
}

在清单8.5中,CheckOutService创建一个实例ReportGenerationService并将其自身作为参数传递给该实例。ReportGenerationService回调CheckOutService以通知它有关报告生成的结果。

In listing 8.5, CheckOutService creates an instance of ReportGenerationService and passes itself to that instance as an argument. ReportGenerationService calls CheckOutService back to notify it about the result of the report generation.

就像过多的抽象层一样,循环依赖会在您尝试阅读和理解代码时增加巨大的认知负担。原因是因为循环依赖没有给你一个明确的起点,你可以从这里开始探索解决方案。要只了解一个类,您必须同时阅读并理解其兄弟姐妹的整个图表。即使是一小部分相互依赖的类也会很快变得难以掌握。

Just like an excessive number of abstraction layers, circular dependencies add tremendous cognitive load when you try to read and understand the code. The reason why is because circular dependencies don’t give you a clear starting point from which you can begin exploring the solution. To understand just one class, you have to read and understand the whole graph of its siblings all at once. Even a small set of interdependent classes can quickly become too hard to grasp.

循环依赖也会干扰测试。你常常不得不求助于接口和模拟来分割类图并隔离单个行为单元,这在测试领域模型时也是行不通的(第 5 章有更多介绍)。

Circular dependencies interfere with testing too. You often have to resort to interfaces and mocking in order to split the class graph and isolate a single unit of behavior, which, again, is a no-go when it comes to testing the domain model (more on that in chapter 5).

请注意,接口的使用仅掩盖了循环依赖的问题。如果您为该接口而不是具体类引入一个接口CheckOutService并使其依赖于该接口,您将在编译时移除循环依赖(图8.11),但该循环在运行时仍然存在。即使编译器不再将此类组合视为循环引用,但理解代码所需的认知负担并没有变小。如果有的话,它只会由于额外的接口而增加。ReportGenerationService

Note that the use of interfaces only masks the problem of circular dependencies. If you introduce an interface for CheckOutService and make ReportGenerationService depend on that interface instead of the concrete class, you remove the circular dependency at compile time (figure 8.11), but the cycle still persists at runtime. Even though the compiler doesn’t regard this class composition as a circular reference anymore, the cognitive load required to understand the code doesn’t become any smaller. If anything, it only increases due to the additional interface.

图 8.11。使用接口,您可以在编译时移除循环依赖,但不会在运行时移除。理解代码所需的认知负荷并没有变小。

Figure 8.11. With an interface, you remove the circular dependency at compile time, but not at runtime. The cognitive load required to understand the code doesn’t become any smaller.

CH08 图11 圆形接口

处理循环依赖的更好方法是完全摆脱它们。重构ReportGenerationService使其既不依赖于CheckOutService自身也不依赖于ICheckOutService接口。将ReportGenerationService其工作结果作为普通值返回,而不是调用CheckOutService(清单8.6)。

A better approach to handle circular dependencies is to get rid of them altogether. Refactor ReportGenerationService such that it depends on neither CheckOutService itself nor the ICheckOutService interface. Make ReportGenerationService return the result of its work as a plain value instead of calling CheckOutService (listing 8.6).

清单 8.6。没有循环依赖的版本

Listing 8.6. A version without circular dependencies

公共类 CheckOutService
{
    public void CheckOut(int orderId)
    {
        var service = new ReportGenerationService();
        报告 report = service.GenerateReport(orderId);

        /* 其他工作 */
    }
}

公共类 ReportGenerationService
{
    公共报告生成报告(int orderId)
    {
        /* ... */
    }
}
public class CheckOutService
{
    public void CheckOut(int orderId)
    {
        var service = new ReportGenerationService();
        Report report = service.GenerateReport(orderId);

        /* other work */
    }
}

public class ReportGenerationService
{
    public Report GenerateReport(int orderId)
    {
        /* ... */
    }
}

但是,几乎不可能消除代码库中的所有循环依赖。但即便如此,您也可以通过使相互依赖的类的其余图尽可能小来最大限度地减少损害。

It’s rarely possible to eliminate all circular dependencies in your code base though. But even then, you can minimize the damage by making the remaining graphs of interdependent classes as small as possible.

8.5.4 在测试中使用多个行为部分

8.5.4  Using multiple act sections in a test

您可能还记得第 3 章,在测试中有多个 arrange、act 或 assert 部分是一种代码味道。这表明该测试检查了多个行为单元,这反过来又阻碍了测试的可维护性。例如,如果您有两个相关的用例,比如用户注册和用户删除,可能很想在单个集成测试中检查这两个用例。这样的测试可以具有以下结构:

As you might remember from chapter 3, having more than one arrange, act, or assert section in a test is a code smell. It’s a sign that this test checks multiple units of behavior, which, in turn, hinders the test’s maintainability. For example, if you have two related use cases, say, user registration and user deletion, it might be tempting to check both of these use cases in a single integration test. Such a test could have the following structure:

  • 安排——准备数据以注册用户。

  • Arrange--Prepare data to register a user with.

  • 行动——呼叫UserController.RegisterUser()

  • Act--Call UserController.RegisterUser().

  • Assert --查询数据库,看是否注册成功。

  • Assert--Query the database to see if the registration is completed successfully.

  • 行动——呼叫UserController.DeleteUser()

  • Act--Call UserController.DeleteUser().

  • 断言——查询数据库以确保用户已被删除。

  • Assert--Query the database to make sure the user is deleted.

这种方法很有说服力,因为用户状态自然地相互流动,第一个动作(注册用户)可以同时作为后续动作(用户删除)的安排阶段。问题是这样的测试会失去重点并且很快就会变得过于臃肿。

This approach is compelling because the user states naturally flow from one another, and the first act (registering a user) can simultaneously serve as an arrange phase for the subsequent act (user deletion). The problem is that such tests lose focus and can quickly become too bloated.

最好通过将每个行为提取到它自己的测试中来拆分测试。这似乎是不必要的工作(毕竟,为什么要在一个就足够的地方创建两个测试?),但从长远来看,这项工作是值得的。让每个测试都集中在一个行为单元上,可以使这些测试更容易理解,并在必要时进行修改。

It’s best to split the test by extracting each act into a test of its own. It may seem like unnecessary work (after all, why create two tests where one would suffice?), but this work pays off in the long run. Having each test focus on a single unit of behavior makes those tests easier to understand and modify when necessary.

该指南的例外是使用很难达到理想状态的进程外依赖项的测试。例如,注册用户会导致在外部银行系统中创建银行帐户。银行为您的组织提供了一个沙箱,您希望在端到端测试中使用该沙箱。问题是沙箱太慢,或者银行可能限制您可以对该沙箱进行的调用次数。在这种情况下,将多个行为组合到一个测试中变得有益,从而减少与有问题的进程外依赖性的交互次数。

The exception to this guideline is tests working with out-of-process dependencies that are hard to bring to a desirable state. Let’s say for example that registering a user results in creating a bank account in an external banking system. The bank has provisioned a sandbox for your organization, and you want to use that sandbox in an end-to-end test. The problem is that the sandbox is too slow, or maybe the bank limits the number of calls you can make to that sandbox. In such a scenario, it becomes beneficial to combine multiple acts into a single test and thus reduce the number of interactions with the problematic out-of-process dependency.

难以管理的进程外依赖关系是编写具有多个 act 部分的测试的唯一合法理由。这就是为什么你永远不应该在一个单元测试中有多个行为——单元测试不适用于进程外依赖。即使是集成测试也很少有多个行为。在实践中,多步测试几乎总是属于端到端测试的范畴。

Hard-to-manage out-of-process dependencies are the only legitimate reason to write a test with more than one act section. This is why you should never have multiple acts in a unit test—unit tests don’t work with out-of-process dependencies. Even integration tests should rarely have several acts. In practice, multi-step tests almost always belong to the category of end-to-end tests.

8.6 如何测试日志功能?

8.6  How to test logging functionality?

日志记录是一个灰色区域,在测试时并不明显。这是一个复杂的话题,我将分成以下几个问题:

Logging is a gray area that isn’t obvious what to do with when it comes to testing. This is a complex topic that I’ll split into the following questions:

  • 您是否应该测试日志记录?

  • Should you test logging at all?

  • 如果是这样,你应该如何测试它?

  • If so, how should you test it?

  • 多少日志记录就足够了?

  • How much logging is enough?

  • 如何传递记录器实例?

  • How to pass logger instances around?

我们将使用示例 CRM 项目作为示例。

We’ll use our sample CRM project as an example.

8.6.1 你应该测试日志记录吗?

8.6.1  Should you test logging?

日志记录是一种横切功能,您可以在代码库的任何部分中使用它。这是一个登录课程的例子User

Logging is a cross-cutting functionality, which you can require in any part of your code base. Here’s an example of logging in the User class.

清单 8.7。登录示例User

Listing 8.7. An example of logging in User

公共类用户
{
    public void ChangeEmail(string newEmail, Company 公司)
    {
        _logger.Info(       
            $"正在将用户 {UserId} 的电子邮件更改为 {newEmail}");

        Precondition.Requires(CanChangeEmail() == null);

        如果(电子邮件 == 新电子邮件)
            返回;

        用户类型 newType = company.IsEmailCorporate(newEmail)
            ?用户类型.员工
            : 用户类型.客户;

        如果(类型!=新类型)
        {
            int delta = newType == UserType.Employee ?1 : -1;
            公司.ChangeNumberOfEmployees(增量);
            _logger.Info(                             
                $"用户{UserId}改变了类型" +      
                $"从 {Type} 到 {newType}");        
        }

        邮箱 = newEmail;
        类型 = 新类型;
        EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));

        _logger.Info(        
            $"用户 {UserId} 的电子邮件已更改");
    }
}
public class User
{
    public void ChangeEmail(string newEmail, Company company)
    {
        _logger.Info(       
            $"Changing email for user {UserId} to {newEmail}");

        Precondition.Requires(CanChangeEmail() == null);

        if (Email == newEmail)
            return;

        UserType newType = company.IsEmailCorporate(newEmail)
            ? UserType.Employee
            : UserType.Customer;

        if (Type != newType)
        {
            int delta = newType == UserType.Employee ? 1 : -1;
            company.ChangeNumberOfEmployees(delta);
            _logger.Info(                            
                $"User {UserId} changed type " +     
                $"from {Type} to {newType}");        
        }

        Email = newEmail;
        Type = newType;
        EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));

        _logger.Info(        
            $"Email is changed for user {UserId}");
    }
}

方法的开始

Start of the method

更改用户类型

Changing the user type

方法结束

End of the method

User清单8.7中的类在日志文件中记录每个ChangeEmail方法的开始和结束,以及用户类型的变化。你应该测试这个功能吗?

The User class in listing 8.7 records in a log file each beginning and ending of the ChangeEmail method, as well as the change of the user type. Should you test this functionality?

一方面,日志记录生成有关应用程序行为的重要信息。但另一方面,日志记录可能无处不在,以致于此功能是否值得进行额外的、相当重要的测试工作,这一点并不明显。是否应该测试日志记录这个问题的答案归结为:日志记录是应用程序可观察行为的一部分还是实现细节?

On the one hand, logging generates important information about the application’s behavior. But on the other hand, logging can be so ubiquitous that it’s not obvious at all if this functionality is worth the additional, quite significant, testing effort. The answer to the question of whether you should test logging comes down to this: is logging part of the application’s observable behavior or is it an implementation detail?

从这个意义上说,它与任何其他功能没有什么不同。日志记录最终会导致进程外依赖项(例如文本文件或数据库)产生副作用。如果这些副作用是要让您的客户、应用程序的客户或开发人员以外的任何其他人观察到的,那么日志记录一种可观察到的行为,因此必须进行测试。如果唯一的受众是开发人员,那么它就是一个可以在没有任何人注意的情况下自由修改的实现细节;在这种情况下,不应对其进行测试。

In that sense, it isn’t different from any other functionality. Logging ultimately results in side effects in an out-of-process dependency such as a text file or a database. If these side effects are meant to be observed by your customer, the application’s clients, or anyone else other than the developers themselves, then logging is an observable behavior and thus must be tested. If the only audience is the developers, then it’s an implementation detail that can be freely modified without anyone noticing; in which case, it shouldn’t be tested.

例如,如果您编写一个日志记录库,那么该库生成的日志是其可观察行为中最重要(也是唯一)的部分。另一个例子是业务人员坚持记录一些关键的应用程序工作流。在这种情况下,日志也成为业务需求,因此必须进行测试。尽管在后一个示例中,您可能还为开发人员提供单独的日志记录。

For example, if you write a logging library, then the logs this library produces are the most important (and the only) part of its observable behavior. Another example is when business people insist on logging some key application workflows. In this case, logs also become a business requirement and thus have to be covered by tests. Although, in the latter example, you might also have a separate logging just for developers.

Steve Freeman 和 Nat Pryce 在他们的书Growing Object-Oriented Software, Guided by Tests(Addison-Wesley Professional,2009 年)中将这两种类型的日志记录称为支持和诊断日志记录:

Steve Freeman and Nat Pryce in their book Growing Object-Oriented Software, Guided by Tests (Addison-Wesley Professional, 2009) call these two types of logging support and diagnostic logging:

  • 支持日志记录生成旨在由支持人员或系统管理员跟踪的消息。

  • Support logging produces messages that are intended to be tracked by support staff or system administrators.

  • 诊断日志可帮助开发人员了解应用程序内部发生的情况。

  • Diagnostic logging helps developers understand what’s going on inside the application.

8.6.2 如何测试日志?

8.6.2  How to test logging?

由于日志记录涉及进程外依赖性,因此在测试它时,适用与涉及进程外依赖性的任何其他功能相同的规则。您需要使用模拟来验证您的应用程序和日志存储之间的交互。

Because logging involves out-of-process dependencies, when it comes to testing it, the same rules apply as with any other functionality that touches out-of-process dependencies. You need to use mocks to verify interactions between your application and the log storage.

在上面引入一个包装器ILogger

Introducing a wrapper on top of ILogger

不过,不要只是模拟界面ILogger。由于支持日志记录是一项业务需求,因此请在您的代码库中明确反映该需求。创建一个特殊的DomainLogger类,在其中明确列出业务所需的所有支持日志记录;验证与该类而不是 raw 的交互ILogger

Don’t just mock out the ILogger interface though. Because support logging is a business requirement, reflect that requirement explicitly in your code base. Create a special DomainLogger class where you explicitly list all the support logging needed for the business; verify interactions with that class instead of the raw ILogger.

例如,假设业务人员要求您记录用户类型的所有更改,但方法开头和结尾的日志记录只是为了调试目的。清单8.8显示了User引入DomainLogger类后类的外观。

For example, let’s say that business people require you to log all changes of the users' types, but the logging at the beginning and the end of the method is there just for debugging purposes. Listing 8.8 shows how the User class looks after introducing a DomainLogger class.

清单 8.8。提取支持登录到DomainLogger

Listing 8.8. Extracting support logging into the DomainLogger class

public void ChangeEmail(string newEmail, Company 公司)
{
    _logger.Info(       
        $"正在将用户 {UserId} 的电子邮件更改为 {newEmail}");

    Precondition.Requires(CanChangeEmail() == null);

    如果(电子邮件 == 新电子邮件)
        返回;

    用户类型 newType = company.IsEmailCorporate(newEmail)
        ?用户类型.员工
        : 用户类型.客户;

    如果(类型!=新类型)
    {
        int delta = newType == UserType.Employee ?1 : -1;
        公司.ChangeNumberOfEmployees(增量);
        _domainLogger.UserTypeHasChanged(    
            UserId, Type, newType);         
    }

    邮箱 = newEmail;
    类型 = 新类型;
    EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));

    _logger.Info(        
        $"用户 {UserId} 的电子邮件已更改");
}
public void ChangeEmail(string newEmail, Company company)
{
    _logger.Info(       
        $"Changing email for user {UserId} to {newEmail}");

    Precondition.Requires(CanChangeEmail() == null);

    if (Email == newEmail)
        return;

    UserType newType = company.IsEmailCorporate(newEmail)
        ? UserType.Employee
        : UserType.Customer;

    if (Type != newType)
    {
        int delta = newType == UserType.Employee ? 1 : -1;
        company.ChangeNumberOfEmployees(delta);
        _domainLogger.UserTypeHasChanged(   
            UserId, Type, newType);         
    }

    Email = newEmail;
    Type = newType;
    EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));

    _logger.Info(        
        $"Email is changed for user {UserId}");
}

诊断记录

Diagnostic logging

支持日志记录

Support logging

诊断记录

Diagnostic logging

诊断日志记录仍然使用旧的logger(类型为ILogger),但支持日志记录现在使用domainLogger类型为 的新实例IDomainLogger。以下清单显示了 的实现方式IDomainLogger

The diagnostic logging still uses the old logger (which is of type ILogger), but the support logging now utilizes the new domainLogger instance of type IDomainLogger. The following listing shows what the implementation of IDomainLogger looks like.

清单 8.9。 DomainLogger作为顶部的包装ILogger

Listing 8.9. DomainLogger as a wrapper on top of ILogger

公共类 DomainLogger :IDomainLogger
{
    私人只读 ILogger _logger;

    公共域记录器(ILogger 记录器)
    {
        _logger = 记录器;
    }

    公共无效 UserTypeHasChanged(
        int userId, UserType oldType, UserType newType)
    {
        _logger.Info(
            $"用户 {userId} 改变了类型" +
            $"从{旧类型}到{新类型}");
    }
}
public class DomainLogger : IDomainLogger
{
    private readonly ILogger _logger;

    public DomainLogger(ILogger logger)
    {
        _logger = logger;
    }

    public void UserTypeHasChanged(
        int userId, UserType oldType, UserType newType)
    {
        _logger.Info(
            $"User {userId} changed type " +
            $"from {oldType} to {newType}");
    }
}

DomainLoggerworks on top of ILogger: 它使用领域语言来声明业务所需的特定日志条目,从而使支持日志记录更易于理解和维护。事实上,这种实现与结构化日志的概念非常相似,在日志文件的后处理和分析方面具有很大的灵活性。

DomainLogger works on top of ILogger: it uses the domain language to declare specific log entries required by the business, thus making support logging easier to understand and maintain. In fact, this implementation is very similar to the concept of structured logging, which enables great flexibility when it comes to log file post-processing and analysis.

了解结构化日志记录

Understanding structured logging

定义 8.3:

Definition 8.3:

结构化日志记录是一种日志记录技术,其中捕获日志数据与该数据的呈现是分离的。

Structured logging is a logging technique where capturing log data is decoupled from the rendering of that data.

传统的日志记录使用简单的文本。像这样的调用logger.Info("User Id is " + 12)首先形成一个字符串,然后将该字符串写入日志存储。这种方法的问题是生成的日志文件由于缺乏结构而难以分析。例如,查看特定类型的消息有多少以及其中有多少与特定用户 ID 相关并不那么容易。您需要为此使用(甚至编写您自己的)特殊工具。

Traditional logging works with simple text. A call like logger.Info("User Id is " + 12) first forms a string and then writes that string to a log storage. The problem with this approach is that the resulting log files are hard to analyze due to the lack of structure. For example, it’s not that easy to see how many messages of a particular type there are and how many of those relate to a specific user ID. You’d need to use (or even write your own) special tooling for that.

相反,结构化日志记录为您的日志存储引入了结构。结构化日志库的使用在表面上看起来很相似:

On the contrary, structured logging introduces structure to your log storage. The use of a structured logging library looks similar on the surface:

logger.Info("用户 ID 为 {UserId}", 12);
logger.Info("User Id is {UserId}", 12);

但其基本行为有很大不同。在幕后,此方法计算消息模板的散列(消息本身存储在查找存储中以提高空间效率)并将该散列与输入参数组合以形成一组捕获的数据。下一步是渲染该数据。您仍然可以像传统日志记录一样使用平面日志文件,但这只是一种可能的呈现方式。您还可以配置日志库以将捕获的数据呈现为 JSON 或 CSV 文件,这样更容易分析(图8.12)。

But its underlying behavior differs significantly. Behind the scenes, this method computes a hash of the message template (the message itself is stored in a lookup storage for space efficiency) and combines that hash with the input parameters to form a set of captured data. The next step is the rendering of that data. You can still have a flat log file as with traditional logging but that’s just one possible rendering. You are also able to configure the logging library to render the captured data as a JSON or a CSV file, where it would be easier to analyze (figure 8.12).

图 8.12。结构化日志记录将日志数据与该数据的呈现分离。您可以设置多个呈现,例如平面日志文件、JSON 或 CSV 文件。

Figure 8.12. Structured logging decouples log data from renderings of that data. You can set up multiple renderings, such as a flat log file, JSON, or CSV file.

CH08 图12 结构化日志记录

DomainLogger清单8.9中的本身并不是结构化记录器,但它以相同的精神运行。再看一遍这个方法:

DomainLogger in listing 8.9 isn’t a structured logger per se, but it operates in the same spirit. Look at this method once again:

公共无效 UserTypeHasChanged(
    int userId, UserType oldType, UserType newType)
{
    _logger.Info(
        $"用户 {userId} 改变了类型" +
        $"从{旧类型}到{新类型}");
}
public void UserTypeHasChanged(
    int userId, UserType oldType, UserType newType)
{
    _logger.Info(
        $"User {userId} changed type " +
        $"from {oldType} to {newType}");
}

您可以查看UserTypeHasChanged()消息模板的哈希。与userIdoldTypenewType参数一起,该散列包含日志数据。该方法的实现将日志数据呈现到平面日志文件中。您还可以通过将日志数据写入 JSON 或 CSV 文件来轻松创建其他渲染。

You can view UserTypeHasChanged() as the message template’s hash. Together with the userId, oldType, and newType parameters, that hash comprises log data. The method’s implementation renders the log data into a flat log file. And you can easily create additional renderings by also writing the log data into a JSON or a CSV file.

为支持和诊断日志记录编写测试

Writing tests for support and diagnostic logging

正如我之前提到的,DomainLogger代表进程外依赖——日志存储。这带来了一个问题:Usernow 与该依赖项进行交互,从而违反了业务逻辑与进程外依赖项通信之间的分离。使用DomainLogger已经过渡User到过于复杂的代码类别,使得测试和维护变得更加困难(有关代码类别的更多详细信息,请参阅第 7 章)。

As I mentioned earlier, DomainLogger represents an out-of-process dependency—the log storage. This poses a problem: User now interacts with that dependency and thus violates the separation between business logic and communication with out-of-process dependencies. The use of DomainLogger has transitioned User to the category of overcomplicated code, making it harder to test and maintain (refer to chapter 7 for more details about code categories).

这个问题可以像我们实现外部系统通知用户电子邮件更改的方式一样解决——借助域事件(同样,详见第 7 章)。您可以引入一个单独的域事件来跟踪用户类型的变化。然后,控制器会将这些更改转换为对DomainLogger.

This problem can be solved the same way we implemented the notification of external systems about changed user emails—with the help of domain events (again, see chapter 7 for details). You can introduce a separate domain event to track changes in the user type. The controller would then convert those changes into calls to DomainLogger.

清单 8.10。替换DomainLoggerUser域事件

Listing 8.10. Replacing DomainLogger in User with a domain event

public void ChangeEmail(string newEmail, Company 公司)
{
    _logger.Info(
        $"正在将用户 {UserId} 的电子邮件更改为 {newEmail}");

    Precondition.Requires(CanChangeEmail() == null);

    如果(电子邮件 == 新电子邮件)
        返回;

    用户类型 newType = company.IsEmailCorporate(newEmail)
        ?用户类型.员工
        : 用户类型.客户;

    如果(类型!=新类型)
    {
        int delta = newType == UserType.Employee ?1 : -1;
        公司.ChangeNumberOfEmployees(增量);
        添加域事件(                    
            新的 UserTypeChangedEvent(       
                UserId, Type, newType));   
    }

    邮箱 = newEmail;
    类型 = 新类型;
    AddDomainEvent(new EmailChangedEvent(UserId, newEmail));

    _logger.Info($"用户 {UserId} 的电子邮件已更改");
}
public void ChangeEmail(string newEmail, Company company)
{
    _logger.Info(
        $"Changing email for user {UserId} to {newEmail}");

    Precondition.Requires(CanChangeEmail() == null);

    if (Email == newEmail)
        return;

    UserType newType = company.IsEmailCorporate(newEmail)
        ? UserType.Employee
        : UserType.Customer;

    if (Type != newType)
    {
        int delta = newType == UserType.Employee ? 1 : -1;
        company.ChangeNumberOfEmployees(delta);
        AddDomainEvent(                    
            new UserTypeChangedEvent(      
                UserId, Type, newType));   
    }

    Email = newEmail;
    Type = newType;
    AddDomainEvent(new EmailChangedEvent(UserId, newEmail));

    _logger.Info($"Email is changed for user {UserId}");
}

使用域事件而不是 DomainLogger

Uses a domain event instead of DomainLogger

请注意,在清单8.10中,现在有两个域事件:UserTypeChangedEventEmailChangedEvent。它们都实现相同的接口 ( IDomainEvent),因此可以存储在同一个集合中。这就是控制器的外观:

Notice that in listing 8.10, there are now two domain events: UserTypeChangedEvent and EmailChangedEvent. Both of them implement the same interface (IDomainEvent) and thus can be stored in the same collection. And this is how the controller looks:

清单 8.11。最新版本UserController

Listing 8.11. Latest version of UserController

public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    用户 user = UserFactory.Create(userData);

    字符串错误 = user.CanChangeEmail();
    如果(错误!= null)
        返回错误;

    对象 [] 公司数据 = _database.GetCompany();
    公司公司 = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(公司);
    _database.SaveUser(用户);
    _eventDispatcher.Dispatch(user.DomainEvents);   

    返回“确定”;
}
public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    User user = UserFactory.Create(userData);

    string error = user.CanChangeEmail();
    if (error != null)
        return error;

    object[] companyData = _database.GetCompany();
    Company company = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(company);
    _database.SaveUser(user);
    _eventDispatcher.Dispatch(user.DomainEvents);   

    return "OK";
}

调度用户域事件

Dispatching user domain events

EventDispatcher是一个新类,它将领域事件转换为对进程外依赖项的调用:

EventDispatcher is a new class that converts domain events into calls to out-of-process dependencies:

  • EmailChangedEvent翻译成_messageBus.SendEmailChangedMessage()

  • EmailChangedEvent translates into _messageBus.SendEmailChangedMessage().

  • UserTypeChangedEvent翻译成_domainLogger.UserTypeHasChanged()

  • UserTypeChangedEvent translates into _domainLogger.UserTypeHasChanged().

使用UserTypeChangedEvent恢复了两种职责之间的分离:域逻辑和与进程外依赖项的通信。现在测试支持日志记录与测试其他非托管依赖项(消息总线)没有任何不同:

The use of UserTypeChangedEvent has restored the separation between the two responsibilities: domain logic and communication with out-of-process dependencies. Testing support logging now isn’t any different from testing the other unmanaged dependency, the message bus:

  • UserTypeChangedEvent单元测试应该检查被测实例User

  • Unit tests should check an instance of UserTypeChangedEvent in the User under test.

  • 单一集成测试应该使用模拟来确保交互DomainLogger到位。

  • The single integration test should use a mock to ensure the interaction with DomainLogger is in place.

请注意,如果您需要在控制器中支持日志记录而不是域类之一,则无需使用域事件。正如您在第 7 章中所记得的那样,控制器协调域模型和进程外依赖项之间的协作。DomainLogger是此类依赖项之一,因此UserController可以直接使用该记录器。

Note that if you need to do support logging in the controller and not one of the domain classes, there’s no need to use domain events. As you remember from chapter 7, controllers orchestrate the collaboration between the domain model and out-of-process dependencies. DomainLogger is one of such dependencies, and thus UserController can use that logger directly.

另请注意,我没有更改类执行诊断日志记录的方式User。仍然在其方法的开头和结尾直接User使用该实例。这是设计使然。诊断日志仅供开发人员使用;您不需要对该功能进行单元测试,因此不必将其排除在域模型之外。loggerChangeEmail

Also notice that I didn’t change the way the User class does diagnostic logging. User still utilizes the logger instance directly in the beginning and at the end of its ChangeEmail method. This is by design. Diagnostic logging is for developers only; you don’t need to unit test this functionality and thus don’t have to keep it out of the domain model.

User不过,尽可能避免使用诊断登录或其他域类。我将在下一节中解释原因。

Still, refrain from the use of diagnostic logging in User or other domain classes when possible. I explain why in the next section.

8.6.3 多少日志记录就足够了?

8.6.3  How much logging is enough?

另一个重要问题是关于最佳日志记录量。多少日志记录就足够了?支持日志记录在这里是不可能的,因为它是一项业务需求。不过,您确实可以控制诊断日志记录。

Another important question is about the optimum amount of logging. How much logging is enough? Support logging is out of the question here because it’s a business requirement. You do have control over diagnostic logging though.

由于以下两个原因,不要过度使用诊断日志记录很重要:

It’s important not to overuse diagnostic logging for the following two reasons:

  • 过多的日志记录会使代码混乱。对于域模型尤其如此。这就是为什么我不建议使用诊断日志记录,User即使从单元测试的角度来看这样的使用是好的;它掩盖了代码。

  • Excessive logging clutters the code. It’s especially true for the domain model. That’s why I don’t recommend using diagnostic logging in User even though such a use is fine from a unit testing perspective; it obscures the code.

  • 日志的信噪比是关键。您登录的越多,就越难找到相关信息。最大化信号;尽量减少噪音。

  • Logs' signal-to-noise ratio is key. The more you log, the harder it is to find relevant information. Maximize the signal; minimize the noise.

尽量不要在域模型中使用诊断日志记录。在大多数情况下,您可以安全地将日志记录从域类移动到控制器。即便如此,当您需要调试某些内容时,也只是暂时求助于诊断日志记录。完成调试后将其删除。理想情况下,您应该只对未处理的异常使用诊断日志记录。

Try not to use diagnostic logging in the domain model at all. In most cases, you can safely move that logging from domain classes to controllers. And even then, resort to diagnostic logging only temporarily when you need to debug something. Remove it once you finish debugging. Ideally, you should use diagnostic logging for unhandled exceptions only.

8.6.4 如何传递记录器实例?

8.6.4  How to pass logger instances around?

最后,最后一个问题是如何在代码中传递 logger 实例。解决这些实例的一种方法是使用静态方法(清单8.12)。

Finally, the last question is how to pass logger instances in the code. One way to resolve these instances is using static methods (listing 8.12).

清单 8.12。存储ILogger在静态字段中

Listing 8.12. Storing ILogger in a static field

公共类用户
{
    私人静态只读 ILogger _logger =    
        LogManager.GetLogger(typeof(User));     

    public void ChangeEmail(string newEmail, Company 公司)
    {
        _logger.Info(
            $"正在将用户 {UserId} 的电子邮件更改为 {newEmail}");

        /* ... */

        _logger.Info($"用户 {UserId} 的电子邮件已更改");
    }
}
public class User
{
    private static readonly ILogger _logger =   
        LogManager.GetLogger(typeof(User));     

    public void ChangeEmail(string newEmail, Company company)
    {
        _logger.Info(
            $"Changing email for user {UserId} to {newEmail}");

        /* ... */

        _logger.Info($"Email is changed for user {UserId}");
    }
}

通过静态方法解析ILogger,存储在私有静态字段中

Resolves ILogger through a static method and stores it in a private static field

Steven van Deursen 和 Mark Seeman 在他们的书Dependency Injection Principles, Practices, Patterns(Manning Publications,2018)中将这种类型的依赖获取环境上下文称为。这是一个反模式。他们的一些论点是:

Steven van Deursen and Mark Seeman in their book Dependency Injection Principles, Practices, Patterns (Manning Publications, 2018) call this type of dependency acquisition ambient context. This is an anti-pattern. A few of their arguments were that:

  • 依赖是隐藏的,很难改变。

  • The dependency is hidden and hard to change.

  • 测试变得更加困难。

  • Testing becomes more difficult.

我完全同意史蒂文和马克的分析。不过,对我来说,环境上下文的主要缺点是它掩盖了代码中的潜在问题。如果将记录器显式注入域类变得非常不方便,以至于您不得不求助于环境上下文,那一定是麻烦的迹象。您要么记录太多,要么使用太多间接层。在任何情况下,环境上下文都不是解决方案。相反,解决问题的根本原因。

I fully agree with Steven and Mark’s analysis. To me, though, the main drawback of ambient context is that it masks potential problems in code. If injecting a logger explicitly into a domain class becomes so inconvenient that you have to resort to ambient context, that’s a certain sign of trouble. You either log too much or use too many layers of indirection. In any case, ambient context is not a solution. Tackle the root cause of the problem instead.

清单 8.13。显式注入记录器

Listing 8.13. Injecting the logger explicitly

public void ChangeEmail(
    字符串 newEmail,   
    公司公司,    
    ILogger 记录器)    
{
    记录器.Info(
        $"正在将用户 {UserId} 的电子邮件更改为 {newEmail}");

    /* ... */

    logger.Info($"用户 {UserId} 的电子邮件已更改");
}
public void ChangeEmail(
    string newEmail,   
    Company company,   
    ILogger logger)    
{
    logger.Info(
        $"Changing email for user {UserId} to {newEmail}");

    /* ... */

    logger.Info($"Email is changed for user {UserId}");
}

方法注入

The method injection

清单8.13显示了一种显式注入记录器的方法——作为方法参数。另一种方法是通过类构造函数。

Listing 8.13 shows one way to explicitly inject the logger—as a method argument. Another way is through the class constructor.

8.6.5 结论

8.6.5  Conclusion

通过此通信是应用程序可观察行为的一部分还是实现细节的镜头来查看与所有进程外依赖项的通信。日志存储在这方面没有任何不同。如果非程序员可以观察到日志,则模拟日志记录功能;否则不要测试它。在下一章中,我们将深入探讨模拟和与之相关的最佳实践。

View communications with all out-of-process dependencies through the lens of whether this communication is part of the application’s observable behavior or an implementation detail. The log storage isn’t any different in that regard. Mock logging functionality if the logs are observable by non-programmers; don’t test it otherwise. In the next chapter, we’ll dive deeper into the topic of mocking and best practices related to it.

8.7 总结

8.7  Summary

  • 集成测试是任何不是单元测试的测试。集成测试验证您的系统如何与进程外依赖项集成。

    • 集成测试涵盖控制器;单元测试涵盖算法和领域模型。

    • 集成测试可以更好地防止回归和重构阻力;单元测试具有更好的可维护性和反馈速度。

  • An integration test is any test that is not a unit test. Integration tests verify how your system works in integration with out-of-process dependencies.

    • Integration tests cover controllers; unit tests cover algorithms and the domain model.

    • Integration tests provide better protection against regressions and resistance to refactoring; unit tests have better maintainability and feedback speed.

  • 集成测试的门槛高于单元测试:它们在防止回归和重构阻力指标方面的得分必须高于单元测试,以抵消更差的可维护性和反馈速度。测试金字塔代表了这种权衡:大多数测试应该是快速且廉价的单元测试,同时进行少量缓慢且昂贵的集成测试,以检查整个系统的正确性。

    • 通过单元测试尽可能多地检查业务场景的边缘情况。使用集成测试来覆盖一条快乐的道路,以及单元测试无法覆盖的任何边缘情况。

    • 测试金字塔的形状取决于项目的复杂性。简单的项目在域模型中的代码很少,因此可以有相同数量的单元测试和集成测试。在最微不足道的情况下,可能没有任何单元测试。

  • The bar for integration tests is higher than for unit tests: the score they have in the metrics of protection against regressions and resistance to refactoring must be higher than that of a unit test to offset the worse maintainability and feedback speed. The test pyramid represents this trade-off: the majority of tests should be fast and cheap unit tests with a smaller number of slow and more expensive integration tests that check correctness of the system as a whole.

    • Check as many of the business scenario’s edge cases as possible with unit tests. Use integration tests to cover one happy path, as well as any edge cases that can’t be covered by unit tests.

    • The shape of the test pyramid depends on the project’s complexity. Simple projects have little code in the domain model and thus can have an equal number of unit and integration tests. In the most trivial cases, there might be no unit tests whatsoever.

  • Fail Fast 原则提倡让错误快速显现出来,是集成测试的可行替代方案。

  • The Fail Fast principle advocates for making bugs manifest themselves quickly and is a viable alternative to integration testing.

  • 托管依赖项是进程外依赖项,只能通过您的应用程序访问。外部无法观察到与托管依赖项的交互。一个典型的例子是应用程序数据库。

  • Managed dependencies are out-of-process dependencies that are only accessible through your application. Interactions with managed dependencies aren’t observable externally. A typical example is the application database.

  • 非托管依赖项是其他应用程序可以访问的进程外依赖项。与非托管依赖项的交互是可以从外部观察到的。典型示例包括 SMTP 服务器和消息总线。

  • Unmanaged dependencies are out-of-process dependencies that other applications have access to. Interactions with unmanaged dependencies are observable externally. Typical examples include an SMTP server and a message bus.

  • 与托管依赖项的通信是实现细节;与非托管依赖项的通信是系统可观察行为的一部分。

  • Communications with managed dependencies are implementation details; communications with unmanaged dependencies are part of your system’s observable behavior.

  • 在集成测试中使用托管依赖项的真实实例;用模拟替换非托管依赖项。

  • Use real instances of managed dependencies in integration tests; replace unmanaged dependencies with mocks.

  • 有时,进程外依赖项会同时表现出托管和非托管依赖项的属性。一个典型的例子是其他应用程序可以访问的数据库。将依赖项的可观察部分视为非托管依赖项:用测试中的模拟替换该部分。将其余依赖项视为托管依赖项:验证其最终状态,而不是与其交互。

  • Sometimes an out-of-process dependency exhibits attributes of both managed and unmanaged dependencies. A typical example is a database that other applications have access to. Treat the observable part of the dependency as an unmanaged dependency: replace that part with mocks in tests. Treat the rest of the dependency as a managed dependency: verify its final state, not interactions with it.

  • 集成测试必须遍历与托管依赖项一起工作的所有层。在一个数据库示例中,这意味着独立于用作输入参数的数据检查该数据库的状态。

  • An integration test must go through all layers that work with a managed dependency. In an example with a database, it means checking the state of that database independently of the data used as input parameters.

  • 具有单一实现的接口不是抽象,并且与实现这些接口的具体类一样不提供松散耦合。试图预测此类接口的未来实现违反了 YAGNI(您不需要它)原则。

  • Interfaces with a single implementation are not abstractions and don’t provide loose coupling any more than the concrete classes that implement those interfaces. Trying to anticipate future implementations for such interfaces violates the YAGNI (You aren’t gonna need it) principle.

  • 将接口与单个实现一起使用的唯一正当理由是启用模拟。仅将此类接口用于非托管依赖项。将具体类用于托管依赖项。

  • The only legitimate reason to use interfaces with a single implementation is to enable mocking. Use such interfaces only for unmanaged dependencies. Use concrete classes for managed dependencies.

  • 具有用于进程内依赖项的单一实现的接口是一个危险信号。此类接口暗示使用模拟来检查域类之间的交互,这导致将测试耦合到代码的实现细节。

  • Interfaces with a single implementation used for in-process dependencies are a red flag. Such Interfaces hint at using mocks to check interactions between domain classes, which leads to coupling tests to the code’s implementation details.

  • 在您的代码库中为域模型提供明确且众所周知的位置。域类和控制器之间的显式边界使得区分单元测试和集成测试变得更容易。

  • Have an explicit and well-known place for the domain model in your code base. The explicit boundary between domain classes and controllers makes it easier to tell unit and integration tests apart.

  • 过多的间接层会对您推理代码的能力产生负面影响。尽可能少的间接层。在大多数后端系统中,您可以只使用其中的三个:域模型、应用程序服务层(控制器)和基础设施层。

  • An excessive number of layers of indirection negatively affect your ability to reason about the code. Have as few layers of indirections as possible. In most backend systems, you can get away with just three of them: the domain model, an application services layer (controllers), and an infrastructure layer.

  • 当您尝试理解代码时,循环依赖会增加认知负担。一个典型的例子是回调(当被调用者通知调用者其工作结果时)。通过引入值对象打破循环;使用该值对象将结果从被调用方返回给调用方。

  • Circular dependencies add a cognitive load when you try to understand the code. A typical example is a callback (when a callee notifies the caller about the result of its work). Break the cycle by introducing a value object; use that value object to return the result from the callee to the caller.

  • 测试中的多个 act 部分仅在该测试与难以进入理想状态的进程外依赖项一起工作时才合理。你永远不应该在一个单元测试中有多个动作,因为单元测试不适用于进程外依赖。多步测试几乎总是属于端到端测试的范畴。

  • Multiple act sections in a test are only justified when that test works with out-of-process dependencies that are hard to bring into a desirable state. You should never have multiple acts in a unit test because unit tests don’t work with out-of-process dependencies. Multi-step tests almost always belong to the category of end-to-end tests.

  • 支持日志是为支持人员和系统管理员准备的;它是应用程序可观察行为的一部分。诊断日志记录有助于开发人员了解应用程序内部正在发生的事情:它是一个实现细节。

  • Support logging is intended for support staff and system administrators; it’s part of the application’s observable behavior. Diagnostic logging helps developers understand what’s going on inside the application: it’s an implementation detail.

  • 由于支持日志记录是一项业务需求,因此请在您的代码库中明确反映该需求。引入一个特殊的DomainLogger类,在其中列出业务所需的所有支持日志记录。

  • Because support logging is a business requirement, reflect that requirement explicitly in your code base. Introduce a special DomainLogger class where you list all the support logging needed for the business.

  • 将支持日志记录视为与进程外依赖项一起使用的任何其他功能。使用领域事件来跟踪领域模型的变化;将这些域事件转换为对DomainLogger控制器的调用。

  • Treat support logging as any other functionality that works with an out-of-process dependency. Use domain events to track changes in the domain model; convert those domain events into calls to DomainLogger in controllers.

  • 不要测试诊断日志记录。与支持日志记录不同,您可以直接在域模型中进行诊断日志记录。

  • Don’t test diagnostic logging. Unlike support logging, you can do diagnostic logging directly in the domain model.

  • 偶尔使用诊断日志记录。过多的诊断日志记录会使代码混乱并破坏日志的信噪比。理想情况下,您应该只对未处理的异常使用诊断日志记录。

  • Use diagnostic logging sporadically. Excessive diagnostic logging clutters the code and damages the logs' signal-to-noise ratio. Ideally, you should only use diagnostic logging for unhandled exceptions.

  • 始终通过构造函数或作为方法参数显式注入所有依赖项,包括记录器。

  • Always inject all dependencies, including loggers, explicitly, either via the constructor or as a method argument.

9

9

模拟最佳实践

Mocking best practices

本章涵盖:

This chapter covers:

  • 最大化模拟的价值

  • Maximizing the value of mocks

  • 用间谍代替模拟

  • Replacing mocks with spies

  • 模拟最佳实践

  • Mocking best practices

你可能还记得第 5 章,mock 是一个测试替身,有助于模拟和检查被测系统与其依赖项之间的交互。你可能还记得第 8 章中提到的,mock 应该只应用于非托管依赖(外部应用程序可以观察到与此类依赖的交互)。将模拟用于其他任何事情都会导致脆弱的测试(缺乏重构阻力指标的测试)。谈到模拟,坚持这一准则大约是成功的三分之二。

As you might remember from chapter 5, a mock is a test double that helps to emulate and examine interactions from the system under test to its dependencies. As you might also remember from chapter 8, mocks should only be applied to unmanaged dependencies (interactions with such dependencies are observable by external applications). Using mocks for anything else results in brittle tests (tests that lack on the metric of resistance to refactoring). When it comes to mocks, adhering to this one guideline is about two thirds of success.

本章展示了剩余的指南,这些指南可以帮助您通过最大化模拟对重构的抵抗和对回归的保护来将集成测试的价值带到绝对极限。我将首先展示模拟的典型用法,描述其缺点,然后演示如何克服这些缺点。

This chapter shows the remaining guidelines that help you to bring the value of your integration tests to the absolute limit by maxing out mocks' resistance to refactoring and protection against regressions. I’ll first show a typical use of mocks, describe its drawbacks, and then demonstrate how you can overcome those drawbacks.

9.1 最大化模拟的价值

9.1  Maximizing mocks' value

将模拟的使用限制在非托管依赖项中很重要,但这只是最大化模拟价值的第一步。这个主题最好用一个例子来解释,所以我将继续使用前面章节中的 CRM 系统作为示例项目。我将提醒您它的功能并展示我们结束的集成测试。之后,您将看到如何在模拟方面改进该测试。

It’s important to limit the use of mocks to unmanaged dependencies, but that’s only the first step on the way to maximizing the value of mocks. This topic is best explained with an example, so I’ll continue using the CRM system from earlier chapters as a sample project. I’ll remind you of its functionality and show the integration test we ended up. After that, you’ll see how that test can be improved with regards to mocking.

您可能还记得,CRM 系统目前仅支持一种用例:更改用户的电子邮件。下面的清单显示了我们在控制器上停止的地方。

As you might recall, the CRM system currently supports only one use case: changing a user’s email. The following listing shows where we left off with the controller.

清单 9.1。用户控制器

Listing 9.1. User controller

公共类用户控制器
{
    私有只读数据库_database;
    私有只读 EventDispatcher _eventDispatcher;

    公共用户控制器(
        数据库数据库,
        IMessageBus 消息总线,
        IDomainLogger domainLogger)
    {
        _database = 数据库;
        _eventDispatcher = new EventDispatcher(
            消息总线,域记录器);
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        object[] userData = _database.GetUserById(userId);
        用户 user = UserFactory.Create(userData);

        字符串错误 = user.CanChangeEmail();
        如果(错误!= null)
            返回错误;

        对象 [] 公司数据 = _database.GetCompany();
        公司公司 = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _database.SaveCompany(公司);
        _database.SaveUser(用户);
        _eventDispatcher.Dispatch(user.DomainEvents);

        返回“确定”;
    }
}
public class UserController
{
    private readonly Database _database;
    private readonly EventDispatcher _eventDispatcher;

    public UserController(
        Database database,
        IMessageBus messageBus,
        IDomainLogger domainLogger)
    {
        _database = database;
        _eventDispatcher = new EventDispatcher(
            messageBus, domainLogger);
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        object[] userData = _database.GetUserById(userId);
        User user = UserFactory.Create(userData);

        string error = user.CanChangeEmail();
        if (error != null)
            return error;

        object[] companyData = _database.GetCompany();
        Company company = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _database.SaveCompany(company);
        _database.SaveUser(user);
        _eventDispatcher.Dispatch(user.DomainEvents);

        return "OK";
    }
}

请注意,不再有诊断日志记录,但支持日志记录(接口IDomainLogger)仍然存在(有关更多详细信息,请参见第 8 章)。此外,清单9.1引入了一个新类:EventDispatcher. 它将域模型生成的域事件转换为对非托管依赖项的调用(控制器以前自己执行的操作),如以下清单所示。

Note that there’s no diagnostic logging anymore, but support logging (the IDomainLogger interface) is still in place (see chapter 8 for more details). Also, listing 9.1 introduces a new class: the EventDispatcher. It converts domain events generated by the domain model into calls to unmanaged dependencies (something that the controller previously did by itself) as shown in the following listing.

清单 9.2。事件调度员

Listing 9.2. Event dispatcher

公共类 EventDispatcher
{
    私人只读 IMessageBus _messageBus;
    私人只读 IDomainLogger _domainLogger;

    公共事件调度程序(
        IMessageBus 消息总线,
        IDomainLogger domainLogger)
    {
        _domainLogger = domainLogger;
        _messageBus = 消息总线;
    }

    public void Dispatch(List<IDomainEvent> 事件)
    {
        foreach(事件中的 IDomainEvent ev)
        {
            派遣(ev);
        }
    }

    私有无效调度(IDomainEvent ev)
    {
        开关(ev)
        {
            案例 EmailChangedEvent emailChangedEvent:
                _messageBus.SendEmailChangedMessage(
                    emailChangedEvent.UserId,
                    emailChangedEvent.NewEmail);
                休息;

            案例 UserTypeChangedEvent userTypeChangedEvent:
                _domainLogger.UserTypeHasChanged(
                    userTypeChangedEvent.UserId,
                    userTypeChangedEvent.OldType,
                    userTypeChangedEvent.NewType);
                休息;
        }
    }
}
public class EventDispatcher
{
    private readonly IMessageBus _messageBus;
    private readonly IDomainLogger _domainLogger;

    public EventDispatcher(
        IMessageBus messageBus,
        IDomainLogger domainLogger)
    {
        _domainLogger = domainLogger;
        _messageBus = messageBus;
    }

    public void Dispatch(List<IDomainEvent> events)
    {
        foreach (IDomainEvent ev in events)
        {
            Dispatch(ev);
        }
    }

    private void Dispatch(IDomainEvent ev)
    {
        switch (ev)
        {
            case EmailChangedEvent emailChangedEvent:
                _messageBus.SendEmailChangedMessage(
                    emailChangedEvent.UserId,
                    emailChangedEvent.NewEmail);
                break;

            case UserTypeChangedEvent userTypeChangedEvent:
                _domainLogger.UserTypeHasChanged(
                    userTypeChangedEvent.UserId,
                    userTypeChangedEvent.OldType,
                    userTypeChangedEvent.NewType);
                break;
        }
    }
}

最后,清单9.3显示了集成测试。此测试遍历所有进程外依赖项(托管和非托管)。

Finally, listing 9.3 shows the integration test. This test goes through all out-of-process dependencies (both managed and unmanaged).

清单 9.3。集成测试

Listing 9.3. Integration test

[事实]
public void Changing_email_from_corporate_to_non_corporate()
{
    // 安排
    var db = new Database(ConnectionString);
    用户 user = CreateUser("user@mycorp.com", UserType.Employee, db);
    CreateCompany("mycorp.com", 1, db);

    var messageBusMock = new Mock<IMessageBus>();    
    var loggerMock = new Mock<IDomainLogger>();     
    var sut = new UserController(
        db, messageBusMock.Object, loggerMock.Object);

    // 行为
    字符串结果 = sut.ChangeEmail(user.UserId, "new@gmail.com");

    //断言
    Assert.Equal("OK", 结果);

    object[] userData = db.GetUserById(user.UserId);
    用户 userFromDb = UserFactory.Create(userData);
    Assert.Equal("new@gmail.com", userFromDb.Email);
    Assert.Equal(UserType.Customer, userFromDb.Type);

    对象 [] 公司数据 = db.GetCompany();
    公司 companyFromDb = CompanyFactory.Create(companyData);
    Assert.Equal(0, companyFromDb.NumberOfEmployees);

    messageBusMock.Verify(                     
        x => x.SendEmailChangedMessage(        
             user.UserId, "new@gmail.com"),   
        次.一次);                           
    loggerMock.Verify(                         
        x => x.UserTypeHasChanged(             
            user.UserId,                      
            用户类型.员工,                
            用户类型.Customer),               
        次.一次);                           
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    // Arrange
    var db = new Database(ConnectionString);
    User user = CreateUser("user@mycorp.com", UserType.Employee, db);
    CreateCompany("mycorp.com", 1, db);

    var messageBusMock = new Mock<IMessageBus>();   
    var loggerMock = new Mock<IDomainLogger>();     
    var sut = new UserController(
        db, messageBusMock.Object, loggerMock.Object);

    // Act
    string result = sut.ChangeEmail(user.UserId, "new@gmail.com");

    // Assert
    Assert.Equal("OK", result);

    object[] userData = db.GetUserById(user.UserId);
    User userFromDb = UserFactory.Create(userData);
    Assert.Equal("new@gmail.com", userFromDb.Email);
    Assert.Equal(UserType.Customer, userFromDb.Type);

    object[] companyData = db.GetCompany();
    Company companyFromDb = CompanyFactory.Create(companyData);
    Assert.Equal(0, companyFromDb.NumberOfEmployees);

    messageBusMock.Verify(                    
        x => x.SendEmailChangedMessage(       
             user.UserId, "new@gmail.com"),   
        Times.Once);                          
    loggerMock.Verify(                        
        x => x.UserTypeHasChanged(            
            user.UserId,                      
            UserType.Employee,                
            UserType.Customer),               
        Times.Once);                          
}

设置模拟

Sets up the mocks

验证与模拟的交互

Verifies the interactions with the mocks

该测试模拟了两个非托管依赖项:IMessageBusIDomainLogger. 我会首先关注IMessageBus。我们将IDomainLogger在本章后面讨论。

This test mocks out two unmanaged dependencies: IMessageBus and IDomainLogger. I’ll focus on IMessageBus first. We’ll discuss IDomainLogger later in this chapter.

9.1.1 验证系统边缘的交互

9.1.1  Verifying interactions at the system edges

现在让我们讨论一下为什么清单9.3中的集成测试使用的 mock在防止回归和抵抗重构方面没有那么好,以及我们如何解决这个问题。

Let’s now discuss why mocks used by the integration test in listing 9.3 aren’t as good in terms of their protection against regressions and resistance to refactoring and how we can fix that.

V03 模拟时,始终遵循以下准则:在系统的最边缘验证与非托管依赖项的交互。

V03When mocking, always adhere to the following guideline: verify interactions with unmanaged dependencies at the very edges of your system.

messageBusMock清单9.3中的问题是IMessageBus接口不在系统的边缘。查看该接口的实现(清单6.4)。

The problem with messageBusMock in listing 9.3 is that the IMessageBus interface doesn’t reside at the system’s edge. Look at that interface’s implementation (listing 6.4).

清单 9.4。消息总线

Listing 9.4. Message bus

公共接口 IMessageBus
{
    void SendEmailChangedMessage(int userId, string newEmail);
}

公共类消息总线:IMessageBus
{
    私有只读 IBus _bus;

    公共无效 SendEmailChangedMessage(
        int userId, 字符串 newEmail)
    {
        _bus.Send("类型:用户邮箱已更改;" +
            $"ID: {userId}; " +
            $"新邮件: {newEmail}");
    }
}

公共接口IBus
{
    无效发送(字符串消息);
}
public interface IMessageBus
{
    void SendEmailChangedMessage(int userId, string newEmail);
}

public class MessageBus : IMessageBus
{
    private readonly IBus _bus;

    public void SendEmailChangedMessage(
        int userId, string newEmail)
    {
        _bus.Send("Type: USER EMAIL CHANGED; " +
            $"Id: {userId}; " +
            $"NewEmail: {newEmail}");
    }
}

public interface IBus
{
    void Send(string message);
}

IMessageBus和接口(以及实现它们的类)都IBus属于我们的代码库。IBus是消息总线 SDK 库(由开发该消息总线的公司提供)之上的包装器。这个包装器封装了非必要的技术细节,例如连接凭证,并公开了一个漂亮干净的接口,用于将任意文本消息发送到总线。是;IMessageBus之上的包装器 IBus它定义了特定于您的域的消息。IMessageBus帮助您将所有此类消息保存在一个地方并在整个应用程序中重用它们。

Both the IMessageBus and IBus interfaces (and the classes implementing them) belong to our code base. IBus is a wrapper on top of the message bus SDK library (provided by the company who develops that message bus). This wrapper encapsulates non-essential technical details, such as connection credentials, and exposes a nice and clean interface for sending arbitrary text messages to the bus. IMessageBus is a wrapper on top of IBus; it defines messages specific to your domain. IMessageBus helps you keep all such messages in one place and reuse them across the application.

可以将IBusIMessageBus接口合并在一起,但那将是一个次优的解决方案。这两项职责——隐藏外部库的复杂性和将所有应用程序消息保存在一个地方——最好分开。这与您在第 8 章中看到的withILogger和的情况相同。实现业务所需的特定日志记录功能,并且它通过在幕后使用通用来实现。IDomainLoggerIDomainLoggerILogger

It’s possible to merge the IBus and IMessageBus interfaces together, but that would be a sub-optimal solution. These two responsibilities—hiding the external library’s complexity and holding all application messages in one place—are best kept separated. It’s the same situation as with ILogger and IDomainLogger that you saw in chapter 8. IDomainLogger implements specific logging functionality required by the business, and it does that by using the generic ILogger behind the scenes.

9.1显示了六边形体系结构的位置IBus和立场:是控制器和消息总线之间类型链中的最后一环,而只是这条路上的中间步骤。IMessageBusIBusIMessageBus

Figure 9.1 shows where IBus and IMessageBus stand from a hexagonal architecture perspective: IBus is the last link in the chain of types between the controller and the message bus, while IMessageBus is only an intermediate step on that way.

图 9.1。 IBus驻留在系统的边缘;IMessageBus只是控制器和消息总线之间类型链中的一个中间环节。模拟IBus而不是IMessageBus实现对回归的最佳保护。

Figure 9.1. IBus resides at the system’s edge; IMessageBus is only an intermediate link in the chain of types between the controller and the message bus. Mocking IBus instead of IMessageBus achieves the best protection against regressions.

CH09图1接口

模拟IBus而不是IMessageBus最大化模拟对回归的保护。您可能还记得第 4 章,防止回归是测试期间执行的代码量的函数。模拟与非托管依赖项通信的最后一个类型会增加集成测试通过的类的数量,从而提高保护。这个准则也是你不想 mock 的原因EventDispatcher。与 . 相比,它离系统边缘更远IMessageBus

Mocking IBus instead of IMessageBus maximizes the mock’s protection against regressions. As you might remember from chapter 4, protection against regressions is a function of the amount of code that gets executed during the test. Mocking the very last type that communicates with the unmanaged dependency increases the number of classes the integration test goes through and thus improves the protection. This guideline is also the reason why you don’t want to mock EventDispatcher. It resides even further away from the edge of the system compared to IMessageBus.

这是将其从重定向IMessageBusIBus. 我省略了清单9.3中没有改变的部分。

Here’s the integration test after retargeting it from IMessageBus to IBus. I’m omitting the parts that didn’t change from listing 9.3.

清单 9.5。集成测试目标IBus

Listing 9.5. Integration test targeting IBus

[事实]
public void Changing_email_from_corporate_to_non_corporate()
{
    var busMock = new Mock<IBus>();
    var messageBus = new MessageBus(busMock.Object);  
    var loggerMock = new Mock<IDomainLogger>();
    var sut = new UserController(db, messageBus, loggerMock.Object);

    /* ... */

    busMock.验证(
        x => x.发送(
            “类型:用户电子邮件已更改;” +    
            $"Id: {user.UserId}; " +          
            "新邮箱:new@gmail.com"),      
        次.Once);
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    var busMock = new Mock<IBus>();
    var messageBus = new MessageBus(busMock.Object);  
    var loggerMock = new Mock<IDomainLogger>();
    var sut = new UserController(db, messageBus, loggerMock.Object);

    /* ... */

    busMock.Verify(
        x => x.Send(
            "Type: USER EMAIL CHANGED; " +   
            $"Id: {user.UserId}; " +         
            "NewEmail: new@gmail.com"),      
        Times.Once);
}

使用具体类而不是接口

Uses a concrete class instead of the interface

验证发送到总线的实际消息

Verifies the actual message sent to the bus

请注意测试现在如何使用具体MessageBus类而不是相应的IMessageBus接口。IMessageBus是一个具有单一实现的接口,正如你从第 8 章记得的那样,模拟是拥有这样的接口的唯一合法理由。因为我们不再模拟IMessageBus,所以这个接口可以被删除,它的用法被替换为MessageBus

Notice how the test now uses the concrete MessageBus class and not the corresponding IMessageBus interface. IMessageBus is an interface with a single implementation, and, as you remember from chapter 8, mocking is the only legitimate reason to have such interfaces. Because we no longer mock IMessageBus, this interface can be deleted and its usages replaced with MessageBus.

还要注意清单6.5中的测试如何检查发送到总线的文本消息。与之前的版本对比:

Also notice how the test in listing 6.5 checks the text message sent to the bus. Compare it to the previous version:

messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
    次.Once);
messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
    Times.Once);

验证对自己编写的自定义类的调用与发送到外部系统的实际文本之间存在巨大差异。外部系统期望来自您的应用程序的文本消息,而不是对诸如MessageBus. 事实上,短信是唯一可以从外部观察到的副作用;参与生成这些消息的类仅仅是实现细节。因此,除了增强对回归的保护之外,验证系统最边缘的交互也提高了对重构的抵抗力。由此产生的测试较少暴露于潜在的误报;无论重构如何,只要保留消息的结构,此类测试就不会变红。

There’s a huge difference between verifying a call to a custom class written by yourself and the actual text sent to external systems. External systems expect text messages from your application not calls to classes like MessageBus. In fact, text messages are the only side effect observable externally; classes that participate in producing those messages are mere implementation details. Thus, in addition to the increased protection against regressions, verifying interactions at the very edges of your system also improves resistance to refactoring. The resulting tests are less exposed to potential false positives; no matter the refactorings, such tests won’t turn red as long as the message’s structure is preserved.

与单元测试相比,这里有相同的机制为集成和端到端测试提供额外的重构阻力:它们与代码库更加分离,因此在低级重构期间不会受到太大影响。

There’s the same mechanism at play here that gives integration and end-to-end tests additional resistance to refactoring compared to unit tests: they are more detached from the code base and, therefore, aren’t affected as much during low-level refactorings.

 

 

[提示] 提示

对非托管依赖项的调用在离开您的应用程序之前会经历几个阶段。选择最后一个这样的阶段。这是确保与外部系统向后兼容的最佳方式,这是 mocks 帮助您实现的目标。

A call to an unmanaged dependency goes through several stages before it leaves your application. Pick the last of such stages. It is the best way to ensure backward compatibility with external systems, which is the goal mocks help you achieve.

9.1.2 用间谍替换模拟

9.1.2  Replacing mocks with spies

你可能还记得第 5 章,间谍是测试替身的变体,其目的与模拟相同。唯一的区别是间谍是手动编写的,而模拟是在模拟框架的帮助下创建的。事实上,间谍通常被称为手写模拟

As you may remember from chapter 5, a spy is a variation of a test double that serves the same purpose as a mock. The only difference is that spies are written manually, whereas mocks are created with the help of a mocking framework. Indeed, spies are often called handwritten mocks.

事实证明,当涉及驻留在系统边缘的类时,间谍优于模拟。Spies 帮助您在断言阶段重用代码,从而减少测试的大小并提高可读性。清单6.6显示了一个在IBus.

It turns out that, when it comes to classes residing at the system edges, spies are superior to mocks. Spies help you reuse code in the assertion phase, thereby reducing the test’s size and improving readability. Listing 6.6 shows an example of a spy that works on top of IBus.

清单 9.6。间谍(也称为手写模拟)

Listing 9.6. A spy (also known as a handwritten mock)

公共接口IBus
{
    无效发送(字符串消息);
}

公共类 BusSpy:IBus
{
    私有列表<字符串> _sentMessages =   
        新列表<字符串>();                

    公共无效发送(字符串消息)
    {
        _sentMessages.Add(消息);        
    }

    公共 BusSpy ShouldSendNumberOfMessages(整数)
    {
        Assert.Equal(number, _sentMessages.Count);
        归还这个;
    }

    public BusSpy WithEmailChangedMessage(int userId, string newEmail)
    {
        string message = "Type: USER EMAIL CHANGED; " +
            $"ID: {userId}; " +
            $"新邮件: {newEmail}";
        断言。包含(                          
            _sentMessages, x => x == 消息);   

        归还这个;
    }
}
public interface IBus
{
    void Send(string message);
}

public class BusSpy : IBus
{
    private List<string> _sentMessages =   
        new List<string>();                

    public void Send(string message)
    {
        _sentMessages.Add(message);        
    }

    public BusSpy ShouldSendNumberOfMessages(int number)
    {
        Assert.Equal(number, _sentMessages.Count);
        return this;
    }

    public BusSpy WithEmailChangedMessage(int userId, string newEmail)
    {
        string message = "Type: USER EMAIL CHANGED; " +
            $"Id: {userId}; " +
            $"NewEmail: {newEmail}";
        Assert.Contains(                         
            _sentMessages, x => x == message);   

        return this;
    }
}

在本地存储所有发送的消息

Stores all sent messages locally

断言消息已发送

Asserts that the message has been sent

以下清单是集成测试的新版本。同样,我只展示了相关部分。

The following listing is a new version of the integration test. Again, I’m showing only the relevant parts.

清单 9.7。使用清单6.6中的间谍

Listing 9.7. Using the spy from listing 6.6

[事实]
public void Changing_email_from_corporate_to_non_corporate()
{
    var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock<IDomainLogger>();
    var sut = new UserController(db, messageBus, loggerMock.Object);

    /* ... */

    busSpy.ShouldSendNumberOfMessages(1)
        .WithEmailChangedMessage(user.UserId, "new@gmail.com");
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock<IDomainLogger>();
    var sut = new UserController(db, messageBus, loggerMock.Object);

    /* ... */

    busSpy.ShouldSendNumberOfMessages(1)
        .WithEmailChangedMessage(user.UserId, "new@gmail.com");
}

清单9.7显示了验证与消息总线的交互现在是如何简洁和富有表现力的,这要归功于BusSpy提供的流畅接口。使用流畅的界面,您可以将多个断言链接在一起,从而形成连贯的、几乎是纯英语的句子。

Listing 9.7 shows how verifying the interactions with the message bus is now succinct and expressive, thanks to the fluent interface BusSpy provides. With that fluent interface, you can chain several assertions together, thus forming cohesive, almost plain-English sentences.

 

 

[提示] 提示

您可以重命名BusSpyBusMock. 正如我之前提到的,mock 和 spy 之间的区别在于实现细节。大多数程序员并不熟悉间谍这个词,所以将间谍重命名为BusMock可以避免同事不必要的混淆。

You can rename BusSpy into BusMock. As I mentioned earlier, the difference between a mock and a spy is an implementation detail. Most programmers aren’t familiar with the term spy though, so renaming the spy into BusMock can save your colleagues unnecessary confusion.

这里有一个合理的问题要问:我们不是绕了一整圈又回到了起点吗?清单9.7中的测试版本看起来很像模拟IMessageBus.

There’s a reasonable question to be asked here: didn’t we just make a full circle and come back to where we started? The version of the test in listing 9.7 looks a lot like the earlier version that mocked IMessageBus.

清单 9.8。断言的早期版本

Listing 9.8. An earlier version of the assertion

messageBusMock.Verify(
    x => x.SendEmailChangedMessage(       
        user.UserId, "new@gmail.com"),   
    次.一次);   
messageBusMock.Verify(
    x => x.SendEmailChangedMessage(      
        user.UserId, "new@gmail.com"),   
    Times.Once);   

与 WithEmailChangedMessage(user.UserId, " new@gmail.com ")相同

Same as WithEmailChangedMessage(user.UserId, "new@gmail.com")

与 ShouldSendNumberOfMessages(1) 相同

Same as ShouldSendNumberOfMessages(1)

这些断言是相似的,因为BusSpyMessageBus都是IBus. 但是两者之间有一个关键的区别:BusSpy是测试代码的一部分,而MessageBus属于生产代码。这种差异很重要,因为在测试中进行断言时不应依赖生产代码

These assertions are similar because both BusSpy and MessageBus are wrappers on top of IBus. But there’s a crucial difference between the two: BusSpy is part of the test code, whereas MessageBus belongs to the production code. This difference is important because you shouldn’t rely on the production code when making assertions in tests.

将您的测试视为审计员。一个好的审核员不会只相信被审核方的话。他们会仔细检查一切。与间谍相同:它提供了一个独立的检查点,当消息结构发生变化时会发出警报。相反,模拟IMessageBus过分信任生产代码。

Think of your tests as auditors. A good auditor wouldn’t just take the auditee’s words at face value; they would double check everything. Same with the spy: it provides an independent checkpoint that raises an alarm when the message structure is changed. On the contrary, a mock upon IMessageBus puts too much trust on the production code.

9.1.3 IDomainLogger呢?

9.1.3  What about IDomainLogger?

之前验证交互的模拟IMessageBus现在针对IBus驻留在系统边缘的 。清单 <listing_9> 显示了集成测试中的当前模拟断言。

The mock that previously verified interactions with IMessageBus is now targeted at IBus, which resides at the system’s edge. Listing <listing_9> shows the current mock assertions in the integration test.

清单 9.9。模拟断言

Listing 9.9. Mock assertions

busSpy.ShouldSendNumberOfMessages(1)      
    .WithEmailChangedMessage(             
        user.UserId, "new@gmail.com");   

loggerMock.Verify(                        
    x => x.UserTypeHasChanged(            
        user.UserId,                     
        用户类型.员工,               
        用户类型.Customer),              
    次.一次);                         
busSpy.ShouldSendNumberOfMessages(1)     
    .WithEmailChangedMessage(            
        user.UserId, "new@gmail.com");   

loggerMock.Verify(                       
    x => x.UserTypeHasChanged(           
        user.UserId,                     
        UserType.Employee,               
        UserType.Customer),              
    Times.Once);                         

检查与 IBus 的交互

Checks interactions with IBus

检查与 IDomainLogger 的交互

Checks interactions with IDomainLogger

请注意,就像MessageBus上面的包装器一样IBusDomainLogger也是上面的包装器ILogger(有关更多详细信息,请参见第 8 章)。ILogger因为这个接口也位于应用程序边界,所以不应该将测试重定向到吗?

Note that just as MessageBus is a wrapper on top of IBus, DomainLogger is a wrapper on top of ILogger (see chapter 8 for more details). Shouldn’t the test be retargeted at ILogger too because this interface also resides at the application boundary?

在大多数项目中,这种重定向是不必要的。虽然记录器和消息总线是非托管依赖项,因此都需要保持向后兼容性,但兼容性的准确性不必相同。对于消息总线,重要的是不允许对消息的结构进行任何更改,因为您永远不知道外部系统将如何对此类更改做出反应。但是文本日志的确切结构对于目标受众(支持人员和系统管理员)来说并不那么重要。重要的是这些日志的存在以及它们携带的信息。因此,IDomainLogger单独的模拟提供了必要的保护级别。

In most projects, such a retargeting isn’t necessary. While the logger and the message bus are unmanaged dependencies and, therefore, both require maintaining backward compatibility, the accuracy of that compatibility doesn’t have to be the same. With the message bus, it’s important not to allow any changes to the structure of the messages because you never know how external systems will react to such changes. But the exact structure of text logs is not that important for the intended audience (support staff and system administrators). What’s important is the existence of those logs and the information they carry. Thus, mocking IDomainLogger alone provides the necessary level of protection.

9.2 模拟最佳实践

9.2  Mocking best practices

到目前为止,您已经学习了两个主要的模拟最佳实践:

You’ve learned two major mocking best practices so far:

  • 仅将模拟应用于非托管依赖项

  • Applying mocks to unmanaged dependencies only

  • 在系统的最边缘验证与这些依赖项的交互

  • Verifying the interactions with those dependencies at the very edges of your system

在本节中,我将解释其余的最佳实践:

In this section, I explain the remaining best practices:

  • 仅在集成测试中使用模拟

  • Using mocks in integration tests only

  • 始终验证对模拟的调用次数

  • Always verifying the number of calls made to the mock

  • 仅模拟您拥有的类型

  • Mocking only types that you own

9.2.1 模拟仅用于集成测试

9.2.1  Mocks are for integration tests only

V03Mocks 仅用于集成测试。不要在单元测试中使用模拟。

V03Mocks are for integration tests only. Don’t use mocks in unit tests.

该指南源于第 7 章中描述的基本原则:业务逻辑和编排的分离。您的代码应该与进程外依赖项进行通信,或者应该很复杂,但绝不能两者兼而有之。这一原则自然会导致形成两个不同的层:域模型(处理复杂性)和控制器(处理通信)。

This guideline stems from the foundational principle described in chapter 7: the separation of business logic and orchestration. Your code should either communicate with out-of-process dependencies or be complex but never both. This principle naturally leads to formation of two distinct layers: the domain model (that handles complexity) and controllers (that handle the communication).

对领域模型的测试属于单元测试的范畴;覆盖控制器的测试是集成测试。因为模拟仅适用于非托管依赖项,并且因为控制器是唯一使用此类依赖项的代码,所以您应该只在测试控制器时应用模拟——在集成测试中。

Tests upon the domain model fall into the category of unit tests; tests covering controllers are integration tests. Because mocks are for unmanaged dependencies only and because controllers are the only code working with such dependencies, you should only apply mocking when testing controllers—in integration tests.

9.2.2 每个测试不只是一个模拟

9.2.2  Not just one mock per test

您有时可能会听到每次测试只有一个模拟的准则。根据这一准则,如果你有多个模拟,你可能会同时测试几件事情。

You might sometimes hear the guideline of having only one mock per test. According to this guideline, if you have more than one mock, you are likely testing several things at a time.

这是一个误解,源于我在第 2 章中提到的一个更基本的误解:单元测试中的一个单元指的是一个代码单元,所有这些单元必须相互隔离地进行测试。相反,术语单元表示行为单元而不是代码单元。实现这样一个行为单元所需的代码量是无关紧要的。它可以跨越多个类、一个类,或者只占用一个小方法。

This is a misconception that follows from a more foundational misunderstanding that I covered in chapter 2: a unit in a unit test refers to a unit of code, and all such units must be tested in isolation from each other. On the contrary, the term unit means a unit of behavior not a unit of code. The amount of code it takes to implement such a unit of behavior is irrelevant. It could span across multiple classes, a single class, or take up just a tiny method.

对于模拟,有相同的原则在起作用:验证一个行为单元需要多少模拟是无关紧要的。在本章的前面,我们用了两个模拟来检查将用户电子邮件从企业更改为非企业的场景:一个用于记录器,另一个用于消息总线。这个数字本来可以更大。事实上,您无法控制在集成测试中使用多少模拟。模拟的数量完全取决于参与操作的非托管依赖项的数量。

With mocks, there’s the same principle at play: it’s irrelevant how many mocks it takes to verify a unit of behavior. Earlier in this chapter, it took us two mocks to check the scenario of changing the user email from corporate to non-corporate: one for the logger and the other one for the message bus. That number could have been larger. In fact, you don’t have control over how many mocks to use in an integration test. The number of mocks depends solely on the number of unmanaged dependencies participating in the operation.

9.2.3 验证调用次数

9.2.3  Verifying the number of calls

当谈到与非托管依赖项的通信时,确保以下两者很重要:

When it comes to communications with unmanaged dependencies, it’s important to ensure both:

  • 预期呼叫的存在

  • The existence of expected calls

  • 没有意外电话

  • The absence of unexpected calls

此要求再次源于需要保持与非托管依赖项的向后兼容性。兼容性必须是双向的:您的应用程序不应该忽略外部系统期望的消息,也不应该产生意外的消息。仅检查被测系统是否发送如下消息是不够的:

This requirement, once again, stems from the need to maintain backward compatibility with unmanaged dependencies. The compatibility must go both ways: your application shouldn’t omit messages external systems expect, and it also shouldn’t produce unexpected messages. It’s not enough to check that the system under test sends a message like:

messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"));
messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"));

您还需要确保此消息只发送一次,如以下清单所示。

You also need to ensure this message is sent exactly once as shown in the following listing.

清单 9.10。使用调用次数验证模拟

Listing 9.10. Verifying the mock with the number of calls

messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
    次.Once);   
messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
    Times.Once);   

确保方法只被调用一次

Ensures the method is called only once

对于大多数模拟库,您还可以明确验证没有对模拟进行其他调用。在 Moq(我选择的模拟库)中,此验证如下面的清单所示。

With most mocking libraries, you can also explicitly verify that no other calls are made on the mock. In Moq (the mocking library of my choice), this verification looks as shown in the following listing.

清单 9.11。验证没有进行其他调用

Listing 9.11. Verifying no other calls were made

messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
    次.Once);
messageBusMock.VerifyNoOtherCalls();   
messageBusMock.Verify(
    x => x.SendEmailChangedMessage(user.UserId, "new@gmail.com"),
    Times.Once);
messageBusMock.VerifyNoOtherCalls();   

附加检查

The additional check

BusSpy也实现了这个功能:

BusSpy implements this functionality too:

总线间谍
    .ShouldSendNumberOfMessages(1)
    .WithEmailChangedMessage(user.UserId, "new@gmail.com");
busSpy
    .ShouldSendNumberOfMessages(1)
    .WithEmailChangedMessage(user.UserId, "new@gmail.com");

间谍的检查ShouldSendNumberOfMessages(1)包括来自模拟的验证Times.Once和验证。VerifyNoOtherCalls()

The spy’s check ShouldSendNumberOfMessages(1) encompasses both Times.Once and VerifyNoOtherCalls() verifications from the mock.

9.2.4 只有你拥有的模拟类型

9.2.4  Only mock types that you own

我想谈的最后一条准则是只模拟您拥有的类型。它首先由 Steve Freeman 和 Nat Pryce 提出。[5]该指南指出,您应该始终在第三方库之上编写自己的适配器,并模拟这些适配器而不是底层类型。他们的一些论点是:

The last guideline I’d like to talk about is mocking only types that you own. It was first introduced by Steve Freeman and Nat Pryce.[5] The guideline states that you should always write your own adapters on top of third-party libraries and mock those adapters instead of the underlying types. A few of their arguments were that:

  • 您通常对第三方代码的工作原理没有深入的了解。

  • You often don’t have a deep understanding of how the third-party code works.

  • 即使该代码已经提供了内置接口,模拟这些接口也是有风险的,因为您必须确保模拟的行为与外部库的实际行为相匹配。

  • Even if that code already provides built-in interfaces, it’s risky to mock those interfaces because you have to be sure the behavior you mock matches what the external library actually does.

  • 适配器抽象第三方代码的非必要技术细节,并在您的应用程序术语中定义与库的关系。

  • Adapters abstract non-essential technical details of the third-party code and define the relationship with the library in your application’s terms.

我完全同意这个分析。实际上,适配器充当您的代码和外部世界之间的反腐败层。[6]这些可以帮助您:

I fully agree with this analysis. Adapters, in effect, act as an anti-corruption layer between your code and the external world.[6] These help you to:

  • 抽象底层库的复杂性

  • Abstract the underlying library’s complexity

  • 仅从库中公开您需要的功能

  • Only expose features you need from the library

  • 并使用您项目的领域语言来做到这一点

  • And do that using your project’s domain language

我们示例 CRM 项目中的界面IBus正是为这个目的服务的。即使底层消息总线的库提供了与 一样漂亮和干净的接口IBus,您仍然最好在它之上引入您自己的包装器。当你升级库时,你永远不知道第三方代码会发生怎样的变化。这样的升级可能会在整个代码库中引起连锁反应!额外的抽象层将这种连锁反应限制在一个类中:适配器本身。

The IBus interface in our sample CRM project served exactly that purpose. Even if the underlying message bus’s library provides as nice and as clean an interface as IBus, you are still better off introducing your own wrapper on top of it. You never know how the third-party code will change when you upgrade the library. Such an upgrade could cause a ripple effect across the whole code base! The additional abstraction layer restricts that ripple effect to just one class: the adapter itself.

请注意,模拟您自己的类型指南不适用于进程内依赖项。正如我之前解释的,模拟仅适用于非托管依赖项。因此,无需抽象内存中或托管依赖项。例如,如果一个库提供日期和时间 API,您可以按原样使用该 API,因为它不会接触到非托管依赖项。同样,只要 ORM 用于访问对外部应用程序不可见的数据库,就无需抽象 ORM。当然,您可以在任何库之上引入您自己的包装器,但除了非托管依赖项之外,几乎不值得为此付出任何努力。

Note that the mock your own types guideline doesn’t apply to in-process dependencies. As I explained previously, mocks are for unmanaged dependencies only. Thus, there’s no need to abstract in-memory or managed dependencies. For instance, if a library provides a date and time API, you can use that API as is because it doesn’t reach out to unmanaged dependencies. Similarly, there’s no need to abstract an ORM as long as it’s used for accessing a database that isn’t visible to external applications. Of course, you can introduce your own wrapper on top of any library, but it’s rarely worth the effort for anything other than unmanaged dependencies.



[5]请参阅由 Steve Freeman 和 Nat Pryce编写的《成长中的面向对象软件》第 69 页(Addison-Wesley Professional,2009 年)。

[5] See page 69 in Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce (Addison-Wesley Professional, 2009).

[6]请参阅Eric Evans 的领域驱动设计:解决软件核心的复杂性问题(Addison-Wesley,2003 年)。

[6] See Domain-Driven Design: Tackling Complexity in the Heart of Software by Eric Evans (Addison-Wesley, 2003).

9.3 总结

9.3  Summary

  • 在系统的边缘验证与非托管依赖项的交互。模拟控制器和非托管依赖项之间类型链中的最后一个类型。这有助于您增强对回归的保护(由于集成测试验证了更多代码)和对重构的抵抗(由于从代码的实现细节中分离模拟)。

  • Verify interactions with an unmanaged dependency at the very edges of your system. Mock the last type in the chain of types between the controller and the unmanaged dependency. This helps you increase both protection against regressions (due to more code being validated by the integration test) and resistance to refactoring (due to detaching the mock from the code’s implementation details).

  • 间谍是手写的模拟。当谈到驻留在系统边缘的类时,间谍优于模拟。它们帮助您在断言阶段重用代码,从而减少测试的大小并提高可读性。

  • Spies are handwritten mocks. When it comes to classes residing at the system’s edges, spies are superior to mocks. They help you reuse code in the assertion phase, thereby reducing the test’s size and improving readability.

  • 进行断言时不要依赖生产代码。在测试中使用一组单独的文字和常量。如有必要,从生产代码中复制这些文字和常量。测试应该提供独立于生产代码的检查点。否则,您可能会产生重言式测试(不验证任何内容并包含语义上无意义的断言的测试)。

  • Don’t rely on production code when making assertions. Use a separate set of literals and constants in tests. Duplicate those literals and constants from the production code if necessary. Tests should provide a checkpoint independent of the production code. Otherwise, you risk producing tautology tests (tests that don’t verify anything and contain semantically meaningless assertions).

  • 并非所有非托管依赖项都需要相同级别的向后兼容性。如果消息的确切结构并不重要,而您只想验证该消息及其携带的信息是否存在,则可以忽略在系统边缘验证与非托管依赖项的交互的准则。典型的例子是日志记录。

  • Not all unmanaged dependencies require the same level of backward compatibility. If the exact structure of the message isn’t important, and you only want to verify the existence of that message and the information it carries, you can ignore the guideline of verifying interactions with unmanaged dependencies at the very edges of your system. The typical example is logging.

  • 因为模拟仅适用于非托管依赖项,并且因为控制器是唯一使用此类依赖项的代码,所以您应该只在测试控制器时应用模拟——在集成测试中。不要在单元测试中使用模拟。

  • Because mocks are for unmanaged dependencies only, and because controllers are the only code working with such dependencies, you should only apply mocking when testing controllers—in integration tests. Don’t use mocks in unit tests.

  • 测试中使用的模拟数量无关紧要。该数量仅取决于参与操作的非托管依赖项的数量。

  • The number of mocks used in a test is irrelevant. That number depends solely on the number of unmanaged dependencies participating in the operation.

  • 确保既存在预期调用又不存在对模拟的意外调用。

  • Ensure both the existence of expected calls and the absence of unexpected calls to mocks.

  • 仅模拟您拥有的类型。在提供对非托管依赖项的访问的第三方库之上编写您自己的适配器。模拟那些适配器而不是底层类型。

  • Only mock types that you own. Write your own adapters on top of third-party libraries that provide access to unmanaged dependencies. Mock those adapters instead of the underlying types.

10

10

测试数据库

Testing the database

本章涵盖:

This chapter covers:

  • 测试数据库的先决条件

  • Prerequisites for testing the database

  • 数据库测试最佳实践

  • Database testing best practices

  • 测试数据生命周期

  • Test data life cycle

  • 在测试中管理数据库事务

  • Managing database transactions in tests

集成测试中的最后一个难题是管理进程外依赖性。托管依赖项最常见的示例是应用程序数据库——其他应用程序无法访问的数据库。

The last piece of the puzzle in integration testing is managed out-of-process dependencies. The most common example of a managed dependency is an application database—a database no other application has access to.

针对真实数据库运行测试提供了针对回归的防弹保护,但这些测试并不容易设置。本章展示了在开始测试数据库之前需要采取的初步步骤:它涵盖了跟踪数据库模式,解释了基于状态和基于迁移的数据库交付方法之间的区别,并演示了为什么应该选择后者优于前者。

Running tests against a real database provides bullet-proof protection against regressions, but those tests aren’t easy to set up. This chapter shows the preliminary steps you need to take before you can start testing your database: it covers keeping track of the database schema, explains the difference between the state-based and migration-based database delivery approaches, and demonstrates why you should choose the latter over the former.

了解基础知识后,您将了解如何在测试期间管理事务、清理剩余数据以及通过消除无关紧要的部分和放大要点来保持测试的小型化。本章重点介绍关系数据库,但许多相同的原则适用于其他类型的数据存储,例如面向文档的数据库甚至纯文本文件存储。

After learning the basics, you’ll see how to manage transactions during the test, clean up leftover data, and keep tests small by eliminating insignificant parts and amplifying the essentials. This chapter focuses on relational databases, but many of the same principles are applicable to other types of data stores such as document-oriented databases or even plain text file storages.

10.1 测试数据库的先决条件

10.1  Prerequisites for testing the database

您可能还记得第 8 章,托管依赖项应该集成测试一样包含在内。这使得使用这些依赖项比非托管依赖项更费力,因为在这里使用模拟是不可能的。但即使在开始编写测试之前,您也必须采取准备步骤来启用集成测试。在本节中,您将看到以下先决条件:

As you might recall from chapter 8, managed dependencies should be included as is in integration tests. That makes working with those dependencies more laborious than unmanaged ones because using a mock here is out of the question. But even before you start writing tests, you must take preparatory steps to enable integration testing. In this section, you’ll see these prerequisites:

  • 将数据库保存在源代码控制系统中

  • Keeping the database in the source control system

  • 为每个开发人员使用单独的数据库实例

  • Using a separate database instance for every developer

  • 将基于迁移的方法应用于数据库交付

  • Applying the migration-based approach to database delivery

但是,就像测试中的几乎所有内容一样,促进测试的实践通常也会改善数据库的健康状况。即使您不编写集成测试,您也会从这些实践中获得价值。

Like almost everything in testing, though, practices that facilitate testing also improve the health of your database in general. You’ll get value out of those practices even if you don’t write integration tests.

10.1.1 将数据库保存在源代码控制系统中

10.1.1  Keeping the database in the source control system

测试数据库的第一步是将数据库模式视为常规代码。与常规代码一样,数据库模式最好存储在 Git 等源代码控制系统中。

The first step on the way to testing the database is treating the database schema as regular code. Just as with regular code, a database schema is best stored in a source control system such as Git.

我从事的项目中,程序员维护一个专用数据库实例,用作参考点(模型数据库)。在开发过程中,所有模式更改都在该实例中累积。在生产部署时,团队比较了生产数据库和模型数据库,使用特殊工具生成升级脚本,并在生产环境中运行这些脚本(图10.1)。

I worked on projects where programmers maintained a dedicated database instance, which served as a reference point (a model database). During development, all schema changes accumulated in that instance. Upon production deployments, the team compared the production and the model databases, used a special tool to generate upgrade scripts, and ran those scripts in production (figure 10.1).

图 10.1。将专用实例作为模型数据库是一种反模式。数据库模式最好存储在源代码控制系统中。

Figure 10.1. Having a dedicated instance as a model database is an anti-pattern. Database schema is best stored in a source control system.

CH10 FIG 1 分贝标准具

使用模型数据库是一种维护数据库模式的糟糕方法。那是因为有

Using a model database is a horrible way to maintain database schema. That’s because there’s

  • 无变更历史。您无法将数据库模式追溯到过去的某个时间点,这在生产中重现错误时可能很重要。

  • No change history. You can’t trace the database schema back to some point in the past, which might be important when reproducing bugs in production.

  • 没有单一的事实来源。模型数据库成为关于开发状态的真实竞争来源。维护两个这样的源(Git 和模型数据库)会产生额外的负担。

  • No single source of truth. The model database becomes a competing source of truth about the state of development. Maintaining two such sources (Git and the model database) creates additional burden.

相反,在源代码控制系统中保留所有数据库模式更新可以帮助您维护单一的真实来源,还可以跟踪数据库更改以及常规代码的更改。不应在源代码控制之外对数据库结构进行任何修改。

On the contrary, keeping all the database schema updates in the source control system helps you to maintain a single source of truth and also to track database changes along with the changes of regular code. No modifications to the database structure should be made outside of the source control.

10.1.2 参考数据是数据库模式的一部分

10.1.2  Reference data is part of the database schema

当谈到数据库模式时,通常会怀疑表、视图、索引、存储过程以及构成数据库构建蓝图的任何其他内容。模式本身以 SQL 脚本的形式表示。在开发过程中的任何时候,您应该能够使用这些脚本来创建您自己的功能齐全、最新的数据库实例。然而,数据库的另一部分属于数据库模式但很少被这样看待——参考数据。

When it comes to the database schema, the usual suspects are tables, views, indexes, stored procedures, and anything else that comprises a blueprint of how the database is constructed. The schema itself is represented in a form of SQL scripts. You should be able to use those scripts to create a fully functional, up-to-date database instance of your own at any time during development. However, there’s another part of the database that belongs to the database schema but is rarely viewed as such—reference data.

定义 10.1:

Definition 10.1:

参考数据是为了使应用程序正常运行而必须预先填充的数据。

Reference data is data that must be pre-populated in order for the application to operate properly.

以前面章节中的 CRM 系统为例。它的用户可以是类型CustomerEmployee。假设您要创建一个包含所有用户类型的表,并从User该表中引入一个外键约束。这样的约束将提供额外的保证,即应用程序永远不会为用户分配不存在的类型。在这种情况下,表的内容UserType将是引用数据,因为应用程序依赖于它的存在才能将用户持久保存在数据库中。

Take the CRM system from the earlier chapters, for example. Its users can be either of type Customer or Employee. Let’s say that you want to create a table with all user types and introduce a foreign key constraint from User to that table. Such a constraint would provide an additional guarantee that the application won’t ever assign a user a non-existent type. In this scenario, the content of the UserType table would be reference data because the application relies on its existence in order to persist users in the database.

 

 

[提示] 提示

有一种简单的方法可以将参考数据与常规数据区分开来:如果您的应用程序可以修改数据,那么它就是常规数据。如果不是,它是参考数据。

There’s a simple way to differentiate reference data from regular data: if your application can modify the data, it’s regular data. If not, it’s reference data.

因为引用数据对于您的应用程序是必不可少的,所以您也应该将其以 SQL 语句的形式与表、视图和数据库架构的其他部分一起保存在源代码控制系统中INSERT

Because reference data is essential for your application, you should keep it in the source control system too, along with tables, views, and other parts of the database schema, in the form of SQL INSERT statements.

请注意,虽然参考数据通常与常规数据分开存储,但有时两者可以共存于同一个表中。为了使这项工作有效,您需要引入一个标志来区分可以修改的数据(常规数据)和不能修改的数据(参考数据),并禁止您的应用程序更改后者。

Note that although reference data is normally stored separately from regular data, the two can sometimes coexist in the same table. To make this work, you need to introduce a flag differentiating data that can be modified (regular data) from data that can’t (reference data) and forbid your application from changing the latter.

10.1.3 每个开发者的独立实例

10.1.3  Separate instance for every developer

针对真实数据库运行测试已经够困难的了。如果您必须与其他开发人员共享该数据库,这将变得更加困难。使用共享数据库阻碍了开发过程,因为:

It’s difficult enough to run tests against a real database. It becomes even more difficult if you have to share that database with other developers. The use of a shared database hinders the development process because:

  • 不同开发人员运行的测试会相互干扰。

  • Tests run by different developers interfere with each other.

  • 非向后兼容的更改会阻碍其他开发人员的工作。

  • Non-backward compatible changes can block the work of other developers.

为每个开发人员保留一个单独的数据库实例。最好在该开发人员自己的机器上,以最大限度地提高测试执行速度。

Keep a separate database instance for every developer. Preferably on that developer’s own machine in order to maximize test execution speed.

10.1.4 基于状态与基于迁移的数据库交付

10.1.4  State-based versus migration-based database delivery

数据库交付有两种主要方法:基于状态基于迁移。基于迁移的方法最初更难实施和维护,但从长远来看,它比基于状态的方法效果更好。

There are two major approaches to database delivery: state-based and migration-based. The migration-based approach is more difficult to implement and maintain initially, but it works much better than the state-based approach in the long run.

基于状态的方法

The state-based approach

基于状态的数据库交付方法类似于我在图10.1中描述的方法。您还有一个在整个开发过程中维护的模型数据库。在部署期间,比较工具会为生产数据库生成脚本,以使其与模型数据库保持同步。不同之处在于,使用基于状态的方法,您实际上没有物理模型数据库作为真实来源。相反,您拥有可用于创建该数据库的 SQL 脚本。脚本存储在源代码管理中。

The state-based approach to database delivery is similar to what I described in figure 10.1. You also have a model database that you maintain throughout development. During deployments, a comparison tool generates scripts for the production database to bring it up-to-date with the model database. The difference is that with the state-based approach, you don’t actually have a physical model database as a source of truth. Instead, you have SQL scripts that you can use to create that database. The scripts are stored in the source control.

在基于状态的方法中,比较工具会完成所有困难的工作。无论生产数据库的状态如何,该工具都会执行使其与模型数据库同步所需的一切:删除不必要的表、创建新表、重命名列等。

In the state-based approach, the comparison tool does all the hard lifting. Whatever the state of the production database, the tool does everything needed to get it in sync with the model database: delete unnecessary tables, create new ones, rename columns, and so on.

基于迁移的方法

The migration-based approach

相反,基于迁移的方法强调使用显式迁移将数据库从一个版本转换到另一个版本(图10.2)。

On the contrary, the migration-based approach emphasizes the use of explicit migrations that transition the database from one version to another (figure 10.2).

图 10.2。基于迁移的数据库交付方法强调使用将数据库从一个版本转换到另一个版本的显式迁移。

Figure 10.2. The migration-based approach to database delivery emphasizes the use of explicit migrations that transition the database from one version to another.

CH10 图 2 迁移

没有工具可以帮助您自动同步生产和开发数据库;你必须自己想出升级脚本。尽管如此,在检测生产数据库架构中未记录的更改时,数据库比较工具仍然很有用。

There’s no tool that would help you automatically synchronize the production and development databases; you have to come up with upgrade scripts yourself. Although, a database comparison tool can still be useful when detecting undocumented changes in the production database schema.

使用这种方法,迁移而不是数据库状态成为您存储在源代码管理中的工件。迁移通常用纯 SQL 脚本表示(流行的工具包括https://flywaydb.orghttps://www.liquibase.org),但也可以使用类似 DSL 的语言编写,并将其翻译成 SQL。此清单显示了一个类的示例,该类表示在 FluentMigrator 库 ( https://github.com/fluentmigrator/fluentmigrator )的帮助下进行的数据库迁移。

With this approach, migrations and not the database state become the artifact you store in the source control. Migrations are usually represented with plain SQL scripts (popular tools include https://flywaydb.org and https://www.liquibase.org) but can also be written using a DSL-like language that gets translated into SQL. This listing shows an example of a class that represents a database migration with the help of the FluentMigrator library (https://github.com/fluentmigrator/fluentmigrator).

清单 10.1。表示迁移的 AC# 类

Listing 10.1. A C# class representing a migration

[迁移(1)]                    
公共类 CreateUserTable:迁移
{
    公共重写 void Up()     
    {
        Create.Table("用户");
    }

    公共重写 void Down()   
    {
        Delete.Table("用户");
    }
}
[Migration(1)]                    
public class CreateUserTable : Migration
{
    public override void Up()     
    {
        Create.Table("Users");
    }

    public override void Down()   
    {
        Delete.Table("Users");
    }
}

迁移数

Migration number

向前迁移

Forward migration

向后迁移(在降级到较早的数据库版本以重现错误时很有用)

Backward migration (helpful when downgrading to an earlier database version to reproduce a bug)

比基于状态的方法更喜欢基于迁移的方法

Prefer the migration-based approach over the state-based one

基于状态和基于迁移的数据库交付方法之间的区别归结为(顾名思义)状态迁移(见图10.3):

The difference between the state-based and migration-based approaches to database delivery comes down to (as their names imply) state versus migrations (see figure 10.3):

  • 基于状态的方法使状态显式(通过将该状态存储在源代码管理中),并让比较工具隐式控制迁移。

  • The state-based approach makes the state explicit (by virtue of storing that state in the source control), and lets the comparison tool implicitly control the migrations.

  • 基于迁移的方法使迁移显式化,但状态隐式化。无法直接查看数据库状态;你必须从迁移中组装它。

  • The migration-based approach makes the migrations explicit but leaves the state implicit. It’s impossible to view the database state directly; you have to assemble it from the migrations.

图 10.3。基于状态的方法使状态显式而迁移隐式;基于迁移的方法做出相反的选择。

Figure 10.3. The state-based approach makes the state explicit and migrations implicit; the migration-based approach makes the opposite choice.

CH10图3对比

这种区别导致不同的权衡。数据库状态的明确性使其更容易处理合并冲突,而明确的迁移有助于解决数据移动问题。

Such a distinction leads to different sets of trade-offs. The explicitness of the database state makes it easier to handle merge conflicts, while explicit migrations help to tackle data motion.

定义 10.2:

Definition 10.2:

数据移动是改变现有数据的形状以使其符合新的数据库模式的过程。

Data motion is the process of changing the shape of existing data so that it conforms to the new database schema.

尽管合并冲突的缓解和数据移动的便利性可能看起来同样重要,但在绝大多数项目中,数据移动比合并冲突重要得多。除非您尚未将您的应用程序发布到生产环境,否则您始终拥有无法简单丢弃的数据。

Although the alleviation of merge conflicts and the ease of data motion might look like equally important benefits, in the vast majority of projects, data motion is much more important than merge conflicts. Unless you haven’t yet released your application to production, you always have data that you can’t simply discard.

例如,将Name列拆分为FirstNameand时LastName,不仅要删除该Name列并创建新的FirstNameandLastName列,还必须编写脚本将所有现有名称拆分为两部分。没有简单的方法可以使用状态驱动的方法来实现此更改;在管理数据方面,比较工具很糟糕。原因是因为虽然数据库模式本身是客观的,这意味着只有一种解释方式,但数据是依赖于上下文的。在生成升级脚本时,没有任何工具可以对数据做出可靠的假设。您必须应用特定于域的规则才能实施适当的转换。

For example, when splitting a Name column into FirstName and LastName, you not only have to drop the Name column and create the new FirstName and LastName columns, you also have to write a script to split all existing names into two pieces. There is no easy way to implement this change using the state-driven approach; comparison tools are awful when it comes to managing data. The reason why is because while the database schema itself is objective, meaning that there is only one way to interpret it, data is context-dependent. No tool can make reliable assumptions about data when generating upgrade scripts. You have to apply domain-specific rules in order to implement proper transformations.

因此,基于状态的方法在绝大多数项目中是不切实际的。不过,您可以在项目尚未发布到生产环境时临时使用它。毕竟,测试数据并不是那么重要,每次更改数据库时都可以重新创建它。但是一旦发布了第一个版本,就必须切换到基于迁移的方法才能正确处理数据移动。

As a result, the state-based approach is impractical in the vast majority of projects. You can use it temporarily, though, while the project has still not been released to production. After all, test data isn’t that important, and you can recreate it every time you change the database. But once you release the first version, you will have to switch to the migration-based approach in order to handle data motion properly.

 

 

[提示] 提示

通过迁移将每个修改应用到数据库模式(包括参考数据)。一旦提交到源代码管理,就不要修改迁移。如果迁移不正确,请创建新迁移而不是修复旧迁移。仅当不正确的迁移可能导致数据丢失时,才对该规则进行例外处理。

Apply every modification to the database schema (including reference data) through migrations. Don’t modify migrations once they are committed to the source control. If a migration is incorrect, create a new migration instead of fixing the old one. Make exceptions to this rule only when the incorrect migration can lead to data loss.

10.2 数据库事务管理

10.2  Database transaction management

数据库事务管理是一个对生产代码和测试代码都很重要的主题。生产代码中的适当事务管理可帮助您避免数据不一致。在测试中,它可以帮助您在接近生产的环境中验证与数据库的集成。

Database transaction management is a topic that’s important for both production and test code. Proper transaction management in production code helps you avoid data inconsistencies. In tests, it helps you verify integration with the database in a close-to-production setting.

在本节中,我将首先展示如何在生产代码(控制器)中处理事务,然后演示如何在集成测试中使用它们。我将继续使用您在前几章中看到的相同 CRM 项目作为示例。

In this section, I’ll first show how to handle transactions in the production code (the controller) and then demonstrate how to use them in integration tests. I’ll continue using the same CRM project you saw in the earlier chapters as an example.

10.2.1 在生产代码中管理数据库事务

10.2.1  Managing database transactions in production code

我们的示例 CRM 项目使用该类Database来处理UserCompanyDatabase在每个方法调用上创建一个单独的 SQL 连接。每个这样的连接都隐式地在幕后打开一个独立的事务,如下面的清单所示。

Our sample CRM project uses the Database class to work with User and Company. Database creates a separate SQL connection on each method call. Every such connection implicitly opens an independent transaction behind the scenes as the following listing shows.

清单 10.2。允许访问数据库的类

Listing 10.2. The class that enables access to the database

公共类数据库
{
    私有只读字符串_connectionString;

    公共数据库(字符串连接字符串)
    {
        _connectionString = 连接字符串;
    }

    公共无效保存用户(用户用户)
    {
        bool isNewUser = user.UserId == 0;

        使用(var连接=
            新的 SqlConnection(_connectionString))   
        {
            /* 根据 isNewUser 插入或更新用户 */
        }
    }

    public void SaveCompany(公司公司)
    {
        使用(var连接=
            新的 SqlConnection(_connectionString))   
        {
            /* 只更新;只有一家公司 */
        }
    }
}
public class Database
{
    private readonly string _connectionString;

    public Database(string connectionString)
    {
        _connectionString = connectionString;
    }

    public void SaveUser(User user)
    {
        bool isNewUser = user.UserId == 0;

        using (var connection =
            new SqlConnection(_connectionString))   
        {
            /* Insert or update the user depending on isNewUser */
        }
    }

    public void SaveCompany(Company company)
    {
        using (var connection =
            new SqlConnection(_connectionString))   
        {
            /* Update only; there's only one company */
        }
    }
}

打开一个数据库事务

Opens a database transaction

因此,用户控制器在单个业务操作期间总共创建了四个数据库事务(清单9.2)。

As a result, the user controller creates a total of four database transactions during a single business operation (listing 9.2).

清单 10.3。用户控制器

Listing 10.3. User controller

public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);   
    用户 user = UserFactory.Create(userData);

    字符串错误 = user.CanChangeEmail();
    如果(错误!= null)
        返回错误;

    对象 [] 公司数据 = _database.GetCompany();   
    公司公司 = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(公司);    
    _database.SaveUser(用户);   
    _eventDispatcher.Dispatch(user.DomainEvents);

    返回“确定”;
}
public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);   
    User user = UserFactory.Create(userData);

    string error = user.CanChangeEmail();
    if (error != null)
        return error;

    object[] companyData = _database.GetCompany();   
    Company company = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(company);   
    _database.SaveUser(user);   
    _eventDispatcher.Dispatch(user.DomainEvents);

    return "OK";
}

打开一个新的数据库事务

Opens a new database transaction

只读操作时开启多个事务没问题;例如,当向外部客户端返回用户信息时。但是,如果业务操作涉及数据突变,则该操作期间发生的所有更新都应该是原子的,以避免不一致。例如,控制器可以成功保留公司,但由于数据库连接问题而在保存用户时失败。结果,公司的用户数可能与数据库中的用户NumberOfEmployees总数不一致。Employee

It’s fine to open multiple transactions during read-only operations; for example, when returning user information to the external client. But if the business operation involves data mutation, all updates taking place during that operation should be atomic in order to avoid inconsistencies. For example, the controller can successfully persist the company but then fail when saving the user due to a database connectivity issue. As a result, the company’s NumberOfEmployees can become inconsistent with the total number of Employee users in the database.

定义 10.3:

Definition 10.3:

原子更新以全有或全无的方式执行。原子更新集中的每个更新必须全部完成或没有任何影响。

Atomic updates are executed in an all-or-nothing manner. Each update in the set of atomic updates must be either complete in its entirety or have no effect whatsoever.

将数据库连接与数据库事务分离

Separating database connections from database transactions

为避免潜在的不一致,您需要在两种类型的决策之间进行区分:

To avoid potential inconsistencies, you need to introduce a separation between two types of decisions:

  • 更新什么数据

  • What data to update

  • 是保留更新还是回滚更新

  • Whether to keep the updates or roll them back

这种分离很重要,因为控制器不能同时做出这些决定。只有当业务操作中的所有步骤都成功时,它才知道是否可以保留更新。它只能通过访问数据库并尝试进行更新来完成这些步骤。Database您可以通过将类拆分为存储库和事务来实现这些职责之间的分离:

Such a separation is important because the controller can’t make these decisions simultaneously. It only knows whether the updates can be kept when all the steps in the business operation have succeeded. And it can only make those steps by accessing the database and trying to make the updates. You can implement the separation between these responsibilities by splitting the Database class into repositories and a transaction:

  • 存储库是允许访问和修改数据库中数据的类。我们的示例项目中将有两个存储库:一个User用于Company.

  • Repositories are classes that enable access to and modification of the data in the database. There will be two repositories in our sample project: one for User and the other for Company.

  • 事务是一个完全提交或回滚数据更新的类。这将是一个自定义类,它依赖于底层数据库的事务来提供数据修改的原子性。

  • Transaction is a class that either commits or rolls back data updates in full. This will be a custom class relying on the underlying database’s transactions to provide atomicity of data modification.

存储库和事务不仅具有不同的职责,而且它们的生命周期也不同。事务存在于整个业务操作过程中,并在操作结束时被处理掉。另一方面,存储库是短暂的。您可以在对数据库的调用完成后立即处理存储库。因此,存储库始终在当前事务之上工作。当连接到数据库时,存储库将自己登记到事务中,以便在该连接期间所做的任何数据修改都可以在以后由事务回滚。

Not only do repositories and transactions have different responsibilities, they have different lifespans too. A transaction lives during the whole business operation and gets disposed of at the very end of it. A repository, on the other hand, is short-lived. You can dispose of a repository as soon as the call to the database is completed. As a result, repositories always work on top of the current transaction. When connecting to the database, a repository enlists itself into the transaction so that any data modifications made during that connection can be later rolled back by the transaction.

10.4显示了控制器和数据库之间的通信在清单9.2中的样子。每个数据库调用都包含在它自己的事务中;更新不是原子的。

Figure 10.4 shows how the communication between the controller and the database looks in listing 9.2. Each database call is wrapped into its own transaction; updates are not atomic.

图 10.4。将每个数据库调用包装到一个单独的事务中会引入由于硬件或软件故障而导致不一致的风险。例如,应用程序可以更新公司的员工人数,但不能更新员工本身。

Figure 10.4. Wrapping each database call into a separate transaction introduces a risk of inconsistencies due to hardware or software failures. For example, the application can update the number of employees in the company but not the employees themselves.

CH10 图 4 无交易

10.5显示了引入显式事务后的应用程序。事务调解控制器和数据库之间的交互。所有四个数据库调用仍然存在,但现在数据修改已完全提交或回滚。

Figure 10.5 shows the application after the introduction of explicit transactions. The transaction mediates interactions between the controller and the database. All four database calls are still there, but now data modifications are either committed or rolled back in full.

图 10.5。事务调解控制器和数据库之间的交互,从而实现原子数据修改。

Figure 10.5. The transaction mediates interactions between the controller and the database and thus enables atomic data modification.

CH10 FIG 5 带交易

清单9.3显示了控制器在引入事务和存储库后的样子。

Listing 9.3 shows how the controller looks after introducing a transaction and repositories.

清单 10.4。用户控制器、存储库和事务

Listing 10.4. User controller, repositories, and a transaction

公共类用户控制器
{
    私有只读事务_transaction;
    私有只读 UserRepository _userRepository;
    私有只读 CompanyRepository _companyRepository;
    私有只读 EventDispatcher _eventDispatcher;

    公共用户控制器(
        交易交易,   
        消息总线消息总线,
        IDomainLogger domainLogger)
    {
        _transaction = 交易;
        _userRepository = new UserRepository(交易);
        _companyRepository = new CompanyRepository(交易);
        _eventDispatcher = new EventDispatcher(
            消息总线,域记录器);
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        对象 [] 用户数据 = _userRepository    
            .GetUserById(用户Id);             
        用户 user = UserFactory.Create(userData);

        字符串错误 = user.CanChangeEmail();
        如果(错误!= null)
            返回错误;

        对象 [] 公司数据 = _companyRepository    
            .GetCompany();                          
        公司公司 = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _companyRepository.SaveCompany(公司);    
        _userRepository.SaveUser(用户);            
        _eventDispatcher.Dispatch(user.DomainEvents);

        _transaction.Commit();   
        返回“确定”;
    }
}

公共类 UserRepository
{
    私有只读事务_transaction;

    public UserRepository(Transaction 交易)   
    {
        _transaction = 交易;
    }

        /* ... */
}

公共类事务:IDisposable
{
    public void Commit() { /* ... */ }
    public void Dispose() { /* ... */ }
}
public class UserController
{
    private readonly Transaction _transaction;
    private readonly UserRepository _userRepository;
    private readonly CompanyRepository _companyRepository;
    private readonly EventDispatcher _eventDispatcher;

    public UserController(
        Transaction transaction,   
        MessageBus messageBus,
        IDomainLogger domainLogger)
    {
        _transaction = transaction;
        _userRepository = new UserRepository(transaction);
        _companyRepository = new CompanyRepository(transaction);
        _eventDispatcher = new EventDispatcher(
            messageBus, domainLogger);
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        object[] userData = _userRepository   
            .GetUserById(userId);             
        User user = UserFactory.Create(userData);

        string error = user.CanChangeEmail();
        if (error != null)
            return error;

        object[] companyData = _companyRepository   
            .GetCompany();                          
        Company company = CompanyFactory.Create(companyData);

        user.ChangeEmail(newEmail, company);

        _companyRepository.SaveCompany(company);   
        _userRepository.SaveUser(user);            
        _eventDispatcher.Dispatch(user.DomainEvents);

        _transaction.Commit();   
        return "OK";
    }
}

public class UserRepository
{
    private readonly Transaction _transaction;

    public UserRepository(Transaction transaction)   
    {
        _transaction = transaction;
    }

        /* ... */
}

public class Transaction : IDisposable
{
    public void Commit() { /* ... */ }
    public void Dispose() { /* ... */ }
}

接受交易

Accepts a transaction

使用存储库而不是数据库类

Uses the repositories instead of the Database class

成功提交事务

Commits the transaction on success

将事务注入存储库

Injects a transaction into a repository

类的内部结构Transaction并不重要,但如果您好奇的话,我TransactionScope在幕后使用 .NET 的标准。关于的重要部分Transaction是它包含两个方法:

The internals of the Transaction class aren’t important, but if you’re curious, I’m using .NET’s standard TransactionScope behind the scenes. The important part about Transaction is that it contains two methods:

  • Commit() 将事务标记为成功。仅当业务操作本身已成功且所有数据修改已准备好持久化时才会调用此方法。

  • Commit() marks the transaction as successful. This is only called when the business operation itself has succeeded, and all data modifications are ready to be persisted.

  • Dispose() 结束事务。这就是在业务运行结束时乱调用。如果Commit()之前被调用过,Dispose()则保留所有数据更新;否则,它会将它们回滚。

  • Dispose() ends the transaction. This is called indiscriminately at the end of the business operation. If Commit() was previously invoked, Dispose() persists all data updates; otherwise, it rolls them back.

Commit()这种和的组合Dispose()保证数据库仅在快乐路径(业务场景的成功执行)期间被更改。这就是为什么Commit()驻留在方法的最后ChangeEmail()。如果出现任何错误,无论是验证错误还是未处理的异常,执行流程都会提前返回,从而阻止提交事务。

Such a combination of Commit() and Dispose() guarantees that the database is altered only during happy paths (the successful execution of the business scenario). That’s why Commit() resides at the very end of the ChangeEmail() method. In the event of any error, be it a validation error or an unhandled exception, the execution flow returns early and thereby prevents the transaction from being committed.

Commit()由控制器调用,因为此方法调用需要决策。尽管调用不涉及决策制定Dispose(),因此您可以将该方法调用委托给基础结构层中的类。实例化控制器并为其提供必要依赖项的同一个类也应该在控制器完成工作后处理事务。

Commit() is invoked by the controller because this method call requires decision-making. There’s no decision-making involved in calling Dispose() though, so you can delegate that method call to a class from the infrastructure layer. The same class that instantiates the controller and provides it with the necessary dependencies should also dispose of the transaction once the controller is done working.

UserRepository请注意requiresTransaction作为构造函数参数的方式。这明确表明存储库始终在事务之上工作;存储库不能自行调用数据库。

Notice how UserRepository requires Transaction as a constructor parameter. This explicitly shows that repositories always work on top of transactions; a repository can’t call the database on its own.

将事务升级为工作单元

Upgrading the transaction to a unit of work

存储库和事务的引入是避免潜在数据不一致的好方法,但还有更好的方法。您可以将Transaction班级升级为一个工作单元。

The introduction of repositories and a transaction is a good way to avoid potential data inconsistencies, but there’s an even better approach. You can upgrade the Transaction class to a unit of work.

定义 10.4:

Definition 10.4:

工作单元维护受业务操作影响的对象列表。操作完成后,工作单元计算出更改数据库所需完成的所有更新,并将这些更新作为一个单元执行(因此称为模式名称)。

A unit of work maintains a list of objects affected by a business operation. Once the operation is completed, the unit of work figures out all updates that need to be done to alter the database and executes those updates as a single unit (hence the pattern name).

工作单元相对于普通事务的主要优势是更新的延迟。与事务不同,工作单元在业务操作结束时执行所有更新,从而最大限度地减少底层数据库事务的持续时间并减少数据拥塞(见图 10.6 。通常,这种模式也有助于减少数据库调用的次数。

The main advantage of a unit of work over a plain transaction is the deferral of updates. Unlike a transaction, a unit of work executes all updates at the end of the business operation, thus minimizing the duration of the underlying database transaction and reducing data congestion (see figure 10.6). Oftentimes, this pattern helps to reduce the number of database calls too.

图 10.6。一个工作单元在业务操作结束时执行所有更新。更新仍然包含在数据库事务中,但该事务的存在时间较短,从而减少了数据拥塞。

Figure 10.6. A unit of work executes all updates at the end of the business operation. The updates are still wrapped in a database transaction, but that transaction lives for a shorter period of time, thus reducing data congestion.

CH10 图 6 工作单元

V03数据库事务也实现了工作单元模式。

V03Database transactions also implement the unit of work pattern.

维护一个已修改对象的列表,然后找出要生成的 SQL 脚本,这看起来像是一项繁重的工作。但实际上,您不需要自己完成这项工作。大多数 ORM(对象关系映射)库为您实现工作单元模式。例如,在 .NET 中,您可以使用 NHibernate 或 Entity Framework,它们都提供了完成所有艰巨任务的类(这些类分别是ISessionDbContext)。以下清单显示了UserController与 Entity Framework 结合使用时的外观。

Maintaining a list of modified objects and then figuring out what SQL script to generate can look like a lot of work. In reality though, you don’t need to do that work yourself. Most ORM (object-relational mapping) libraries implement the unit of work pattern for you. In .NET, for example, you can use NHibernate or Entity Framework, both of which provide classes that do all the hard lifting (those classes are ISession and DbContext, respectively). The following listing shows how UserController looks in combination with Entity Framework.

清单 10.5。实体框架的用户控制器

Listing 10.5. User controller with Entity Framework

公共类用户控制器
{
    私有只读 CrmContext _context;
    私有只读 UserRepository _userRepository;
    私有只读 CompanyRepository _companyRepository;
    私有只读 EventDispatcher _eventDispatcher;

    公共用户控制器(
        CrmContext 上下文,   
        消息总线消息总线,
        IDomainLogger domainLogger)
    {
        _context = 上下文;
        _userRepository = 新的 UserRepository(
            语境);   
        _companyRepository = new CompanyRepository(
            语境);   
        _eventDispatcher = new EventDispatcher(
            消息总线,域记录器);
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        用户 user = _userRepository.GetUserById(userId);

        字符串错误 = user.CanChangeEmail();
        如果(错误!= null)
            返回错误;

        公司公司 = _companyRepository.GetCompany();

        user.ChangeEmail(newEmail, company);

        _companyRepository.SaveCompany(公司);
        _userRepository.SaveUser(用户);
        _eventDispatcher.Dispatch(user.DomainEvents);

        _context.SaveChanges();   
        返回“确定”;
    }
}
public class UserController
{
    private readonly CrmContext _context;
    private readonly UserRepository _userRepository;
    private readonly CompanyRepository _companyRepository;
    private readonly EventDispatcher _eventDispatcher;

    public UserController(
        CrmContext context,   
        MessageBus messageBus,
        IDomainLogger domainLogger)
    {
        _context = context;
        _userRepository = new UserRepository(
            context);   
        _companyRepository = new CompanyRepository(
            context);   
        _eventDispatcher = new EventDispatcher(
            messageBus, domainLogger);
    }

    public string ChangeEmail(int userId, string newEmail)
    {
        User user = _userRepository.GetUserById(userId);

        string error = user.CanChangeEmail();
        if (error != null)
            return error;

        Company company = _companyRepository.GetCompany();

        user.ChangeEmail(newEmail, company);

        _companyRepository.SaveCompany(company);
        _userRepository.SaveUser(user);
        _eventDispatcher.Dispatch(user.DomainEvents);

        _context.SaveChanges();   
        return "OK";
    }
}

CrmContext 替换事务。

CrmContext replaces Transaction.

CrmContext是一个自定义类,包含域模型和数据库之间的映射(它继承自 Entity Framework 的DbContext)。清单6.4中的控制器使用CrmContext而不是Transaction. 因此

CrmContext is a custom class that contains mapping between the domain model and the database (it inherits from Entity Framework’s DbContext). The controller in listing 6.4 uses CrmContext instead of Transaction. As a result

  • 这两个存储库现在都在 之上工作,就像它们在以前的版本中CrmContext工作一样。Transaction

  • Both repositories now work on top of CrmContext, just as they worked on top of Transaction in the previous version.

  • 控制器通过context.SaveChanges()而不是将更改提交到数据库transaction.Commit()

  • The controller commits changes to the database via context.SaveChanges() instead of transaction.Commit().

请注意,不再需要UserFactoryandCompanyFactory了,因为 Entity Framework 现在充当原始数据库数据和域对象之间的映射器。

Notice that there’s no need for UserFactory and CompanyFactory anymore because Entity Framework now serves as a mapper between the raw database data and domain objects.

非关系数据库中的数据不一致

Data inconsistencies in non-relational databases

使用关系数据库时很容易避免数据不一致:所有主要关系数据库都提供原子更新,可以根据需要跨越任意多行。但是如何使用非关系数据库(如 MongoDB)实现相同级别的保护?

It’s easy to avoid data inconsistencies when using a relational database: all major relational databases provide atomic updates that can span across as many rows as needed. But how do you achieve the same level of protection with a non-relational database such as MongoDB?

大多数非关系数据库的问题是缺乏传统意义上的事务;仅在单个文档中保证原子更新。如果一项业务操作影响多个文档,则很容易出现不一致。(在非关系数据库中,文档相当于一行

The problem with most non-relational databases is the lack of transactions in the classical sense; atomic updates are guaranteed only within a single document. If a business operation affects multiple documents, it becomes prone to inconsistencies. (In non-relational databases, a document is the equivalent of a row.)

非关系数据库从不同的角度解决不一致问题:它们要求您设计文档,以便没有业务操作一次修改多个文档。这是可能的,因为文档比关系数据库中的行更灵活。单个文档可以存储任何形状和复杂性的数据,从而捕获即使是最复杂的业务操作的副作用。

Non-relational databases approach inconsistencies from a different angle: they require you to design your documents such that no business operation modifies more than one of those documents at a time. This is possible because documents are more flexible than rows in relational databases. A single document can store data of any shape and complexity and thus capture side effects of even the most sophisticated business operations.

例如,在领域驱动设计中,有一条指导原则说您不应该为每个业务操作修改多个聚合。该准则服务于相同的目标:保护您免受数据不一致的影响。该指南仅适用于使用文档数据库的系统,其中每个文档对应一个聚合。

In Domain-Driven Design, for example, there’s a guideline saying that you shouldn’t modify more than one aggregate per business operation. This guideline serves the same goal: protecting you from data inconsistencies. The guideline is only applicable to systems that work with document databases, where each document corresponds to one aggregate.

10.2.2 在集成测试中管理数据库事务

10.2.2  Managing database transactions in integration tests

在集成测试中管理数据库事务时,请遵循以下准则:不要在测试部分之间重复使用数据库事务或工作单元。CrmContext以下清单显示了在将该测试切换到实体框架后在集成测试中重用的示例。

When it comes to managing database transactions in integration tests, adhere to the following guideline: don’t reuse database transactions or units of work between sections of the test. The following listing shows an example of reusing CrmContext in the integration test after switching that test to Entity Framework.

清单 10.6。集成测试重用CrmContext

Listing 10.6. Integration test reusing CrmContext

[事实]
public void Changing_email_from_corporate_to_non_corporate()
{
    使用(var上下文=                    
        新的 CrmContext(ConnectionString))   
    {
        // 安排
        var 用户存储库 =                   
            new UserRepository(context);       
        var companyRepository =               
            新建 CompanyRepository(context);   
        var user = new User(0, "user@mycorp.com",
            UserType.Employee, false);
        userRepository.SaveUser(用户);
        var company = new Company("mycorp.com", 1);
        companyRepository.SaveCompany(公司);
        context.SaveChanges();   

        var busSpy = new BusSpy();
        var messageBus = new MessageBus(busSpy);
        var loggerMock = new Mock<IDomainLogger>();
        var sut = new UserController(
            语境,   
            消息总线,
            loggerMock.Object);

        // 行为
        字符串结果 = sut.ChangeEmail(user.UserId, "new@gmail.com");

        //断言
        Assert.Equal("OK", 结果);

        用户 userFromDb = userRepository    
            .GetUserById(user.UserId);     
        Assert.Equal("new@gmail.com", userFromDb.Email);
        Assert.Equal(UserType.Customer, userFromDb.Type);

        公司 companyFromDb = companyRepository   ❹.GetCompany 
            ();                          
        Assert.Equal(0, companyFromDb.NumberOfEmployees);

        busSpy.ShouldSendNumberOfMessages(1)
            .WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(
            x => x.UserTypeHasChanged(
                user.UserId, UserType.Employee, UserType.Customer),
            次.Once);
    }
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    using (var context =                    
        new CrmContext(ConnectionString))   
    {
        // Arrange
        var userRepository =                  
            new UserRepository(context);      
        var companyRepository =               
            new CompanyRepository(context);   
        var user = new User(0, "user@mycorp.com",
            UserType.Employee, false);
        userRepository.SaveUser(user);
        var company = new Company("mycorp.com", 1);
        companyRepository.SaveCompany(company);
        context.SaveChanges();   

        var busSpy = new BusSpy();
        var messageBus = new MessageBus(busSpy);
        var loggerMock = new Mock<IDomainLogger>();
        var sut = new UserController(
            context,   
            messageBus,
            loggerMock.Object);

        // Act
        string result = sut.ChangeEmail(user.UserId, "new@gmail.com");

        // Assert
        Assert.Equal("OK", result);

        User userFromDb = userRepository   
            .GetUserById(user.UserId);     
        Assert.Equal("new@gmail.com", userFromDb.Email);
        Assert.Equal(UserType.Customer, userFromDb.Type);

        Company companyFromDb = companyRepository   
            .GetCompany();                          
        Assert.Equal(0, companyFromDb.NumberOfEmployees);

        busSpy.ShouldSendNumberOfMessages(1)
            .WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(
            x => x.UserTypeHasChanged(
                user.UserId, UserType.Employee, UserType.Customer),
            Times.Once);
    }
}

创建上下文

Creates a context

在编排部分使用上下文

Uses the context in the arrange section

……在行动中

… in act

……断言

… and in assert

清单6.5中的测试在所有三个部分中使用了相同的实例CrmContext:arrange、act 和 assert。这是一个问题,因为这种工作单元的重用会创建一个与控制器在生产中的体验不匹配的环境。在生产中,每个业务操作都有一个独占的CrmContext. 该实例是在控制器方法调用之前创建的,并在之后立即被处理掉。

The test in listing 6.5 uses the same instance of CrmContext in all three sections: arrange, act, and assert. This is a problem because such a reuse of the unit of work creates an environment that doesn’t match what the controller experiences in production. In production, each business operation has an exclusive instance of CrmContext. That instance is created right before the controller method invocation and gets disposed of immediately after.

为避免行为不一致的风险,集成测试应尽可能接近地复制生产环境,这意味着行为部分不得CrmContext与其他任何人共享。arrange 和 assert 部分CrmContext也必须获得它们自己的实例,因为,正如您可能从第 8 章记得的那样,独立于用作输入参数的数据检查数据库的状态很重要。尽管 assert 部分确实独立于 arrange 部分查询用户和公司,但这些部分仍然共享相同的数据库上下文。该上下文可以(许多 ORM 确实如此)缓存请求的数据以提高性能。

To avoid the risk of inconsistent behavior, integration tests should replicate the production environment as close as possible, which means the act section must not share CrmContext with anyone else. The arrange and assert sections must get their own instances of CrmContext too, because, as you might remember from chapter 8, it’s important to check the state of the database independently of the data used as input parameters. And although the assert section does query the user and the company independently of the arrange section, these sections still share the same database context. That context can (and many ORMs do) cache the requested data for performance improvements.

 

 

[提示] 提示

在集成测试中至少使用三个事务或工作单元:每个安排、操作和断言部分一个。

Use at least three transactions or units of work in an integration test: one per each arrange, act, and assert sections.

10.3 测试数据生命周期

10.3  Test data life cycle

共享数据库提出了相互隔离集成测试的问题。要解决这个问题,您需要:

The shared database raises the problem of isolating integration tests from each other. To solve this problem, you need to:

  • 按顺序执行集成测试

  • Execute integration tests sequentially

  • 删除测试运行之间的剩余数据

  • Remove leftover data between test runs

总的来说,您的测试不应该依赖于数据库的状态。您的测试应该自行将该状态带到所需条件。

Overall, your tests shouldn’t depend on the state of the database. Your tests should bring that state to the required condition on their own.

10.3.1 并行与顺序测试执行

10.3.1  Parallel versus sequential test execution

集成测试的并行执行需要付出巨大的努力。您必须确保所有测试数据都是唯一的,这样就不会违反数据库约束,并且测试不会意外地依次获取输入数据。清理剩余数据也变得更加棘手。按顺序运行集成测试比花时间尝试从中挤出额外的性能更实用。

Parallel execution of integration tests involves significant effort. You have to ensure all test data is unique so that no database constraints are violated, and tests don’t accidentally pick up input data after each other. Cleaning up leftover data also becomes trickier. It’s more practical to run integration tests sequentially rather than spend time trying to squeeze additional performance out of them.

大多数单元测试框架允许您定义单独的测试集合并有选择地禁用其中的并行化。创建两个这样的集合(用于单元测试和集成测试),然后使用集成测试禁用集合中的测试并行化。

Most unit testing frameworks allow you to define separate test collections and selectively disable parallelization in them. Create two of such collections (for unit and integration tests), and then disable test parallelization in the collection with the integration tests.

作为替代方案,您可以使用容器并行化测试。例如,您可以将模型数据库放在 Docker 映像上,并从该映像为每个集成测试实例化一个新容器。但在实践中,这种方法会产生过多的额外维护负担。使用 Docker,您不仅需要跟踪数据库本身,还需要:

As an alternative, you could parallelize tests using containers. For example, you could put the model database on a Docker image and instantiate a new container from that image for each integration test. In practice, though, this approach creates too much of an additional maintenance burden. With Docker, you not only have to keep track of the database itself, but you also need to:

  • 维护 Docker 镜像

  • Maintain Docker images

  • 确保每个测试都有自己的容器实例

  • Make sure each test gets its own container instance

  • 批量集成测试(因为您很可能无法一次创建所有容器实例)

  • Batch integration tests (because you most likely won’t be able to create all container instances at once)

  • 处理用过的容器

  • Dispose of used up containers

我不建议使用容器,除非你绝对需要最小化集成测试的执行时间。同样,每个开发人员只拥有一个数据库实例更为实用。不过,您可以在 Docker 中运行该单个实例。我提倡反对过早的并行化,而不是使用 Docker 本身。

I don’t recommend using containers unless you absolutely need to minimize your integration tests' execution time. Again, it’s more practical to have just one database instance per developer. You can run that single instance in Docker though. I advocate against premature parallelization, not the use of Docker per se.

10.3.2 清除测试运行之间的数据

10.3.2  Clearing data between test runs

关于如何在测试运行之间清理剩余数据,有四个选项:

There are four options as to how you can clean up leftover data between test runs:

  • 在每次测试之前恢复数据库备份。-- 这种方法解决了数据清理的问题,但比其他三个选项慢得多。即使使用容器,删除容器实例和创建新实例通常也需要几秒钟,这会迅速增加测试套件的总执行时间。

  • Restoring a database backup before each test.--This approach addresses the problem of data clean up but is much slower than the other three options. Even with containers, the removal of a container instance and creation of a new one usually takes several seconds, which quickly adds to the total test suite execution time.

  • 在测试结束时清理数据。-- 这种方法速度很快,但容易跳过清理阶段。如果构建服务器在测试过程中崩溃,或者您在调试器中关闭了测试,输入数据将保留在数据库中并影响进一步的测试运行。

  • Cleaning up data at the end of a test.--This method is fast but susceptible to skipping the cleanup phase. If the build server crashes in the middle of the test, or you shut the test down in the debugger, the input data remains in the database and affects further test runs.

  • 将每个测试包装在数据库事务中并且从不提交它,以便自动回滚测试和 SUT 所做的所有更改。--这种方法解决了跳过清理阶段的问题,但带来了另一个问题:引入一个包罗万象的事务会导致生产环境和测试环境之间的行为不一致。这与重用工作单元的问题相同:额外的事务创建了一个不同于生产的设置。

  • Wrap each test in a database transaction and never commit it so that all changes made by the test and the SUT are rolled back automatically.--This approach solves the problem of skipping the cleanup phase but poses another issue: the introduction of an overarching transaction can lead to inconsistent behavior between production and test environments. It’s the same problem as with reusing a unit of work: the additional transaction creates a setup that’s different than that of production.

  • 在测试开始时清理数据。——这是最好的选择。它工作速度很快,不会导致不一致的行为,并且不容易意外跳过清理阶段。

  • Cleaning up data at the beginning of a test.--This is the best option. It works fast, doesn’t result in inconsistent behavior, and isn’t prone to accidentally skipping the cleanup phase.

 

 

[提示] 提示

不需要单独的拆卸阶段;将该阶段作为安排部分的一部分实施。

There’s no need for a separate teardown phase; implement that phase as part of the arrange section.

数据删除本身必须以特定顺序完成,以遵守数据库的外键约束。我有时看到人们使用复杂的算法来找出表之间的关系并自动生成删除脚本,甚至禁用所有完整性约束并在之后重新启用它们。这是不必要的。手动编写 SQL 脚本:它更简单并且可以让您更精细地控制删除过程。

The data removal itself must be done in a particular order to honor the database’s foreign key constraints. I sometimes see people use sophisticated algorithms to figure out relationships between tables and automatically generate the deletion script or even disable all integrity constraints and re-enable them afterwards. This is unnecessary. Write the SQL script manually: it’s simpler and gives you more granular control over the deletion process.

为所有集成测试引入一个基类,并将删除脚本放在那里。使用这样的基类,您将在每次测试开始时自动运行脚本。

Introduce a base class for all integration tests and put the deletion script there. With such a base class, you will have the script run automatically at the start of each test.

清单 10.7。集成测试的基类

Listing 10.7. Base class for integration tests

公共抽象类 IntegrationTests
{
    私有常量字符串 ConnectionString = "...";

    受保护的 IntegrationTests()
    {
        清除数据库();
    }

    私有无效 ClearDatabase()
    {
        字符串查询 =
            “从 dbo 中删除。[用户];” +    
            “从 dbo.Company 中删除;”;   

        使用 (var connection = new SqlConnection(ConnectionString))
        {
            var command = new SqlCommand(查询,连接)
            {
                CommandType = CommandType.文本
            };

            connection.Open();
            命令.ExecuteNonQuery();
        }
    }
}
public abstract class IntegrationTests
{
    private const string ConnectionString = "...";

    protected IntegrationTests()
    {
        ClearDatabase();
    }

    private void ClearDatabase()
    {
        string query =
            "DELETE FROM dbo.[User];" +   
            "DELETE FROM dbo.Company;";   

        using (var connection = new SqlConnection(ConnectionString))
        {
            var command = new SqlCommand(query, connection)
            {
                CommandType = CommandType.Text
            };

            connection.Open();
            command.ExecuteNonQuery();
        }
    }
}

删除脚本

Deletion script

删除脚本必须删除所有常规数据,但不删除任何参考数据。参考数据以及数据库模式的其余部分应该由迁移单独控制。

The deletion script must remove all regular data but none of the reference data. Reference data, along with the rest of the database schema, should be controlled solely by migrations.

10.3.3 避免内存数据库

10.3.3  Avoid in-memory databases

另一种将集成测试相互隔离的方法是用内存中的类似物(例如 SQLite)替换数据库。内存数据库看起来很有用,因为它们:

Another way to isolate integration tests from each other is by replacing the database with an in-memory analogue, such as SQLite. In-memory databases can seem beneficial because they:

  • 不需要删除测试数据

  • Don’t require removal of test data

  • 工作更快

  • Work faster

  • 可以在每次测试运行时实例化

  • Can be instantiated per each test run

因为内存数据库不是共享依赖项,所以集成测试实际上变成了单元测试(假设数据库是项目中唯一的托管依赖项),类似于第 10.3.1 节中描述的容器方法。

Because in-memory databases aren’t shared dependencies, integration tests in effect become unit tests (assuming the database is the only managed dependency in the project), similar to the approach with containers described in section 10.3.1.

尽管有所有这些好处,我还是不建议使用内存数据库,因为它们在功能方面与常规数据库不一致。这又是生产环境和测试环境不匹配的问题。由于常规数据库和内存数据库之间的差异,您的测试很容易遇到误报或(更糟!)漏报。您永远不会通过此类测试获得良好的保护,并且无论如何都必须手动进行大量回归测试。

In spite of all these benefits, I don’t recommend using in-memory databases because they aren’t consistent functionality-wise with regular databases. This is, once again, the problem of a mismatch between production and test environments. Your tests can easily run into false positives or (worse!) false negatives due to the differences between the regular and in-memory databases. You’ll never gain good protection with such tests and will have to do a lot of regression testing manually anyway.

 

 

[提示] 提示

在测试中使用与生产中相同的数据库管理系统 (DBMS)。版本或版本不同通常没问题,但供应商必须保持不变。

Use the same database management system (DBMS) in tests as in production. It’s usually fine for the version or edition to differ, but the vendor must remain the same.

10.4 在测试部分重用代码

10.4  Reusing code in test sections

集成测试会很快变得太大,从而失去可维护性指标。保持集成测试尽可能短很重要,但不要将它们相互耦合或影响可读性。即使是最短的测试也不应该相互依赖。他们还应该保留测试场景的完整上下文,并且不应该要求您检查测试类的不同部分以了解正在发生的事情。

Integration tests can quickly grow too large and thus lose on the maintainability metric. It’s important to keep integration tests as short as possible but do that without coupling them to each other or affecting readability. Even the shortest tests shouldn’t depend on one another. They also should preserve the full context of the test scenario and shouldn’t require you to examine different parts of the test class to understand what’s going on.

缩短集成的最佳方法是将技术性的、与业务无关的部分提取到私有方法或辅助类中。作为附带奖励,您将可以重用这些位。在本节中,我将展示如何缩短测试的所有三个部分:安排、行动和断言。

The best way to shorten integration is by extracting technical, non-business related bits into private methods or helper classes. As a side bonus, you’ll get to reuse those bits. In this section, I’ll show how to shorten all three sections of the test: arrange, act, and assert.

10.4.1 在排列部分重用代码

10.4.1  Reusing code in arrange sections

以下清单显示了我们的集成测试在为其每个部分提供单独的数据库上下文(工作单元)后的样子。

The following listing shows how our integration test looks after providing a separate database context (unit of work) for each of its sections.

清单 10.8。使用三个数据库上下文进行集成测试

Listing 10.8. Integration test with three database contexts

[事实]
public void Changing_email_from_corporate_to_non_corporate()
{
    // 安排
    用户用户;
    使用 (var context = new CrmContext(ConnectionString))
    {
        var userRepository = new UserRepository(context);
        var companyRepository = new CompanyRepository(上下文);
        user = new User(0, "user@mycorp.com",
            UserType.Employee, false);
        userRepository.SaveUser(用户);
        var company = new Company("mycorp.com", 1);
        companyRepository.SaveCompany(公司);

        context.SaveChanges();
    }

    var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock<IDomainLogger>();

    字符串结果;
    使用 (var context = new CrmContext(ConnectionString))
    {
        var sut = new UserController(
            上下文、消息总线、loggerMock.Object);

        // 行为
        结果 = sut.ChangeEmail(user.UserId, "new@gmail.com");
    }

    //断言
    Assert.Equal("OK", 结果);

    使用 (var context = new CrmContext(ConnectionString))
    {
        var userRepository = new UserRepository(context);
        var companyRepository = new CompanyRepository(上下文);

        用户 userFromDb = userRepository.GetUserById(user.UserId);
        Assert.Equal("new@gmail.com", userFromDb.Email);
        Assert.Equal(UserType.Customer, userFromDb.Type);

        公司 companyFromDb = companyRepository.GetCompany();
        Assert.Equal(0, companyFromDb.NumberOfEmployees);

        busSpy.ShouldSendNumberOfMessages(1)
            .WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(
            x => x.UserTypeHasChanged(
                user.UserId, UserType.Employee, UserType.Customer),
            次.Once);
    }
}
[Fact]
public void Changing_email_from_corporate_to_non_corporate()
{
    // Arrange
    User user;
    using (var context = new CrmContext(ConnectionString))
    {
        var userRepository = new UserRepository(context);
        var companyRepository = new CompanyRepository(context);
        user = new User(0, "user@mycorp.com",
            UserType.Employee, false);
        userRepository.SaveUser(user);
        var company = new Company("mycorp.com", 1);
        companyRepository.SaveCompany(company);

        context.SaveChanges();
    }

    var busSpy = new BusSpy();
    var messageBus = new MessageBus(busSpy);
    var loggerMock = new Mock<IDomainLogger>();

    string result;
    using (var context = new CrmContext(ConnectionString))
    {
        var sut = new UserController(
            context, messageBus, loggerMock.Object);

        // Act
        result = sut.ChangeEmail(user.UserId, "new@gmail.com");
    }

    // Assert
    Assert.Equal("OK", result);

    using (var context = new CrmContext(ConnectionString))
    {
        var userRepository = new UserRepository(context);
        var companyRepository = new CompanyRepository(context);

        User userFromDb = userRepository.GetUserById(user.UserId);
        Assert.Equal("new@gmail.com", userFromDb.Email);
        Assert.Equal(UserType.Customer, userFromDb.Type);

        Company companyFromDb = companyRepository.GetCompany();
        Assert.Equal(0, companyFromDb.NumberOfEmployees);

        busSpy.ShouldSendNumberOfMessages(1)
            .WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(
            x => x.UserTypeHasChanged(
                user.UserId, UserType.Employee, UserType.Customer),
            Times.Once);
    }
}

您可能还记得第 3 章,在测试的排列部分之间重用代码的最佳方法是引入私有工厂方法。例如,以下清单创建了一个用户。

As you might remember from chapter 3, the best way to reuse code between the tests' arrange sections is to introduce private factory methods. For example, the following listing creates a user.

清单 10.9。创建用户的单独方法

Listing 10.9. A separate method creating a user

私人用户创建用户(
    string email, UserType 类型, bool isEmailConfirmed)
{
    使用 (var context = new CrmContext(ConnectionString))
    {
        var user = new User(0, email, type, isEmailConfirmed);
        var repository = new UserRepository(context);
        存储库.SaveUser(用户);

        context.SaveChanges();

        返回用户;
    }
}
private User CreateUser(
    string email, UserType type, bool isEmailConfirmed)
{
    using (var context = new CrmContext(ConnectionString))
    {
        var user = new User(0, email, type, isEmailConfirmed);
        var repository = new UserRepository(context);
        repository.SaveUser(user);

        context.SaveChanges();

        return user;
    }
}

您还可以为方法的参数定义默认值,如下一个清单所示。

You can also define default values for the method’s arguments, like that shown in the next listing.

清单 10.10。向工厂添加默认值

Listing 10.10. Adding default values to the factory

私人用户创建用户(
    字符串电子邮件 = "user@mycorp.com" ,
    UserType 类型 = UserType.Employee ,
    布尔 isEmailConfirmed =)
{
    /* ... */
}
private User CreateUser(
    string email = "user@mycorp.com",
    UserType type = UserType.Employee,
    bool isEmailConfirmed = false)
{
    /* ... */
}

使用默认值,您可以有选择地指定参数,从而进一步缩短测试。参数的选择性使用还强调了哪些参数与测试场景相关。下一个清单证明了这一点。

With default values, you can specify arguments selectively and thus shorten the test even further. The selective use of arguments also emphasizes which of those arguments are relevant to the test scenario. The next listing demonstrates that.

清单 10.11。使用工厂方法

Listing 10.11. Using the factory method

用户 user = CreateUser(
    电子邮件:“user@mycorp.com”,
    类型:用户类型。员工);
User user = CreateUser(
    email: "user@mycorp.com",
    type: UserType.Employee);

对象母与测试数据生成器

Object Mother versus Test Data Builder

清单9.910.11中显示的模式称为 Object Mother。

The pattern shown in listings 9.9 and 10.11 is called the Object Mother.

定义 10.5:

Definition 10.5:

Object Mother是帮助创建测试装置(测试运行的对象)的类或方法

Object Mother is a class or method that helps create test fixtures (objects the test runs against).

还有另一种模式有助于实现在排列部分中重用代码的相同目标:Test Data Builder。它的工作方式类似于 Object Mother,但公开了一个流畅的接口而不是简单的方法。这是一个测试数据生成器使用示例:

There’s another pattern that helps achieve the same goal of reusing code in arrange sections: Test Data Builder. It works similar to the Object Mother but exposes a fluent interface instead of plain methods. Here’s a Test Data Builder usage example:

用户 user = new UserBuilder()
    .WithEmail("user@mycorp.com")
    .WithType(UserType.Employee)
    。建造();
User user = new UserBuilder()
    .WithEmail("user@mycorp.com")
    .WithType(UserType.Employee)
    .Build();

Test Data Builder 略微提高了测试的可读性,但需要太多的样板文件。出于这个原因,我建议坚持使用 Object Mother(至少在 C# 中,您将可选参数作为语言功能)。

Test Data Builder slightly improves test readability but requires too much boilerplate. For that reason, I recommend sticking to the Object Mother (at least in C#, where you have optional arguments as a language feature).

工厂方法放在哪里?

Where to put factory methods?

当您开始提炼测试的要点并将技术细节转移到工厂方法时,您将面临将这些方法放在哪里的问题。他们应该和测试在同一个班级吗?基础IntegrationTests班?或者在一个单独的助手类中?

When you start distilling the tests' essentials and move the technicalities out to factory methods, you face the question of where to put those methods. Should they reside in the same class as the tests? The base IntegrationTests class? Or in a separate helper class?

从简单开始。默认情况下将工厂方法放在同一个类中。只有当代码重复成为一个重要问题时,才将它们移动到单独的帮助器类中。不要将工厂方法放在基类中;为必须在每个测试中运行的代码保留该类,例如数据清理。

Start simple. Place the factory methods in the same class by default. Move them into separate helper classes only when code duplication becomes a significant issue. Don’t put the factory methods in the base class; reserve that class for code that has to run in every test, such as data clean up.

10.4.2 在行为部分重用代码

10.4.2   Reusing code in act sections

集成测试中的每个行为部分都涉及创建数据库事务或工作单元。这是清单9.7中 act 部分当前的样子:

Every act section in integration tests involves the creation of a database transaction or a unit of work. This is how the act section currently looks in listing 9.7:

字符串结果;
使用 (var context = new CrmContext(ConnectionString))
{
    var sut = new UserController(
        上下文、消息总线、loggerMock.Object);

    // 行为
    结果 = sut.ChangeEmail(user.UserId, "new@gmail.com");
}
string result;
using (var context = new CrmContext(ConnectionString))
{
    var sut = new UserController(
        context, messageBus, loggerMock.Object);

    // Act
    result = sut.ChangeEmail(user.UserId, "new@gmail.com");
}

这部分也可以减少。您可以引入一个接受委托的方法,其中包含需要调用哪个控制器函数的信息。然后,该方法将通过创建数据库上下文来装饰控制器调用,如以下清单所示。

This section can also be reduced. You can introduce a method accepting a delegate with the information of what controller function needs to be invoked. The method would then decorate the controller invocation with the creation of a database context as shown in the following listing.

清单 10.12。装饰器方法

Listing 10.12. Decorator method

私有字符串执行(
    Func<UserController, 字符串> func,   
    消息总线消息总线,
    IDomainLogger 记录器)
{
    使用 (var context = new CrmContext(ConnectionString))
    {
        变种控制器=新用户控制器(
            上下文、消息总线、记录器);
        返回函数(控制器);
    }
}
private string Execute(
    Func<UserController, string> func,   
    MessageBus messageBus,
    IDomainLogger logger)
{
    using (var context = new CrmContext(ConnectionString))
    {
        var controller = new UserController(
            context, messageBus, logger);
        return func(controller);
    }
}

委托定义了一个控制器函数。

Delegate defines a controller function.

使用这个装饰器方法,您可以将测试的行为部分归结为几行:

With this decorator method, you can boil down the test’s act section to just a couple lines:

字符串结果=执行(
    x => x.ChangeEmail(user.UserId, "new@gmail.com"),
    messageBus, loggerMock.Object);
string result = Execute(
    x => x.ChangeEmail(user.UserId, "new@gmail.com"),
    messageBus, loggerMock.Object);

10.4.3 在断言部分重用代码

10.4.3  Reusing code in assert sections

最后,断言部分也可以缩短。最简单的方法是引入类似于CreateUser和 的辅助方法CreateCompany

Finally, the assert section can be shortened too. The easiest way to do that is to introduce helper methods similar to CreateUser and CreateCompany.

清单 10.13。提取查询逻辑后的数据断言

Listing 10.13. Data assertions after extracting the querying logic

用户 userFromDb = QueryUser(user.UserId);   
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);

公司 companyFromDb = QueryCompany();    
Assert.Equal(0, companyFromDb.NumberOfEmployees);
User userFromDb = QueryUser(user.UserId);   
Assert.Equal("new@gmail.com", userFromDb.Email);
Assert.Equal(UserType.Customer, userFromDb.Type);

Company companyFromDb = QueryCompany();   
Assert.Equal(0, companyFromDb.NumberOfEmployees);

新的辅助方法

New helper methods

您可以更进一步,为这些数据断言创建一个流畅的接口,类似于您在第 9 章中使用BusSpy. 在 C# 中,可以使用扩展方法在现有域类之上实现流畅的接口,如以下清单所示。

You can take a step further and create a fluent interface for these data assertions, similar to what you saw in chapter 9 with BusSpy. In C#, a fluent interface on top of existing domain classes can be implemented using extension methods as shown in the following listing.

清单 10.14。数据断言的流畅接口

Listing 10.14. Fluent interface for data assertions

公共静态类 UserExternsions
{
    public static User ShouldExist(这个用户用户)
    {
        断言.NotNull(用户);
        返回用户;
    }

    public static User WithEmail(此用户用户,字符串电子邮件)
    {
        Assert.Equal(email, user.Email);
        返回用户;
    }
}
public static class UserExternsions
{
    public static User ShouldExist(this User user)
    {
        Assert.NotNull(user);
        return user;
    }

    public static User WithEmail(this User user, string email)
    {
        Assert.Equal(email, user.Email);
        return user;
    }
}

有了这个流畅的界面,断言变得更容易阅读:

With this fluent interface, the assertions become much easier to read:

用户 userFromDb = QueryUser(user.UserId);
userFromDb
    .ShouldExist()
    .WithEmail("new@gmail.com")
    .WithType(UserType.Customer);

公司 companyFromDb = QueryCompany();
companyFromDb
    .ShouldExist()
    .WithNumberOfEmployees(0);
User userFromDb = QueryUser(user.UserId);
userFromDb
    .ShouldExist()
    .WithEmail("new@gmail.com")
    .WithType(UserType.Customer);

Company companyFromDb = QueryCompany();
companyFromDb
    .ShouldExist()
    .WithNumberOfEmployees(0);

10.4.4 测试是否创建了过多的数据库事务?

10.4.4  Does the test create too many database transactions?

在之前进行的所有简化之后,集成测试变得更具可读性,因此也更易于维护。但是有一个缺点:该测试现在总共使用了五个数据库事务(工作单元),而之前它只使用了三个。

After all the simplifications made earlier, the integration test has become more readable and, therefore, more maintainable. There’s one drawback though: the test now uses a total of five database transactions (units of work), where before it used only three.

清单 10.15。移除所有技术细节后的集成测试

Listing 10.15. Integration test after moving all technicalities out of it

公共类 UserControllerTests:IntegrationTests
{
    [事实]
    public void Changing_email_from_corporate_to_non_corporate()
    {
        // 安排
        用户 user = CreateUser(           
            电子邮件:“user@mycorp.com”,
            类型:用户类型。员工);
        CreateCompany("mycorp.com", 1);   

        var busSpy = new BusSpy();
        var messageBus = new MessageBus(busSpy);
        var loggerMock = new Mock<IDomainLogger>();

        // 行为
        字符串结果=执行(   
            x => x.ChangeEmail(user.UserId, "new@gmail.com"),
            messageBus, loggerMock.Object);

        //断言
        Assert.Equal("OK", 结果);

        用户 userFromDb = QueryUser(user.UserId);   
        userFromDb
            .ShouldExist()
            .WithEmail("new@gmail.com")
            .WithType(UserType.Customer);

        公司 companyFromDb = QueryCompany();   
        companyFromDb
            .ShouldExist()
            .WithNumberOfEmployees(0);

        busSpy.ShouldSendNumberOfMessages(1)
            .WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(
            x => x.UserTypeHasChanged(
                user.UserId, UserType.Employee, UserType.Customer),
            次.Once);
    }
}
public class UserControllerTests : IntegrationTests
{
    [Fact]
    public void Changing_email_from_corporate_to_non_corporate()
    {
        // Arrange
        User user = CreateUser(           
            email: "user@mycorp.com",
            type: UserType.Employee);
        CreateCompany("mycorp.com", 1);   

        var busSpy = new BusSpy();
        var messageBus = new MessageBus(busSpy);
        var loggerMock = new Mock<IDomainLogger>();

        // Act
        string result = Execute(   
            x => x.ChangeEmail(user.UserId, "new@gmail.com"),
            messageBus, loggerMock.Object);

        // Assert
        Assert.Equal("OK", result);

        User userFromDb = QueryUser(user.UserId);   
        userFromDb
            .ShouldExist()
            .WithEmail("new@gmail.com")
            .WithType(UserType.Customer);

        Company companyFromDb = QueryCompany();   
        companyFromDb
            .ShouldExist()
            .WithNumberOfEmployees(0);

        busSpy.ShouldSendNumberOfMessages(1)
            .WithEmailChangedMessage(user.UserId, "new@gmail.com");
        loggerMock.Verify(
            x => x.UserTypeHasChanged(
                user.UserId, UserType.Employee, UserType.Customer),
            Times.Once);
    }
}

在幕后实例化一个新的数据库上下文

Instantiates a new database context behind the scenes

数据库事务数量的增加是否是一个问题?如果是,您可以采取什么措施?额外的数据库上下文在某种程度上是个问题,因为它们会使测试变慢,但对此无能为力。这是有价值测试的不同方面之间权衡的另一个例子:这次是在快速反馈和可维护性之间进行权衡。在这种特殊情况下,为了可维护性而进行权衡和交换性能是值得的。性能下降应该不会那么严重,尤其是当数据库位于开发人员的机器上时。同时,可维护性方面的收益是相当可观的。

Is the increased number of database transactions a problem and, if so, what can you do about it? The additional database contexts are a problem to some degree because they make the test slower, but there’s not much that could be done about it. It’s another example of a trade-off between different aspects of a valuable test: this time between fast feedback and maintainability. It’s worth it to make that trade-off and exchange performance for maintainability in this particular case. The performance degradation shouldn’t be that significant, especially when the database is located on the developer’s machine. At the same time, the gains in maintainability are quite substantial.

10.5 常见数据库测试题

10.5  Common database testing questions

在本章的最后一节中,我想回答与数据库测试相关的常见问题,并简要重申第 8 章和第 9 章中提出的一些要点。

In this last section of the chapter, I’d like to answer common questions related to database testing, as well as briefly re-iterate some important points made in chapters 8 and 9.

10.5.1 你应该测试阅读吗?

10.5.1  Should you test reads?

在最后几章中,我们使用了一个更改用户电子邮件的示例场景。此场景是写操作的示例(在数据库和其他进程外依赖项中留下副作用的操作)。大多数应用程序都包含写入和读取操作。读取操作的一个示例是将用户信息返回给外部客户端。您应该同时测试写入和读取吗?

Throughout the last several chapters, we worked with a sample scenario of changing the user email. This scenario is an example of a write operation (an operation that leaves a side effect in the database and other out-of-process dependencies). Most applications contain both write and read operations. An example of a read operation would be returning the user information to the external client. Should you test both writes and reads?

彻底测试写入至关重要,因为风险很高。写入操作中的错误通常会导致数据损坏,这不仅会影响您的数据库,还会影响外部应用程序。覆盖写入的测试非常有价值,因为它们可以防止此类错误。

It’s crucial to thoroughly test writes because the stakes are high. Mistakes in write operations often lead to data corruption, which can affect not only your database but external applications too. Tests that cover writes are highly valuable due to the protection they provide against such mistakes.

但对于读取而言情况并非如此:读取操作中的错误通常不会产生不利后果。因此,测试读取的阈值应该高于写入的阈值。只测试最复杂或最重要的读取操作;忽略其余部分。

This is not the case for reads though: a bug in a read operation usually doesn’t have as detrimental consequences. Therefore, the threshold for testing reads should be higher than that for writes. Test only the most complex or important read operations; disregard the rest.

请注意,读取中也不需要域模型。领域建模的主要目标之一是封装。而且,您可能还记得第 5 章和第 6 章,封装是关于根据任何更改保持数据一致性。缺少数据更改使得读取的封装毫无意义。事实上,您在读取时也不需要成熟的 ORM,例如 NHibernate 或 Entity Framework。你最好使用纯 SQL,由于绕过了不必要的抽象层,它在性能方面优于 ORM(图10.7)。

Note that there’s no need for a domain model in reads either. One of the main goals of domain modeling is encapsulation. And, as you might remember from chapters 5 and 6, encapsulation is about preserving data consistency in light of any changes. The lack of data changes makes encapsulation of reads pointless. In fact, you don’t need a fully-fledged ORM such as NHibernate or Entity Framework in reads either. You are better off using plain SQL, which is superior performance-wise to the ORMs, thanks to bypassing unnecessary layers of abstraction (figure 10.7).

图 10.7。读取中不需要领域模型。而且由于读取错误的成本低于写入错误,因此也不需要集成测试。

Figure 10.7. There’s no need for a domain model in reads. And because the cost of a mistake in reads is lower than that in writes, there’s not as much need for integration testing either.

CH10 FIG 7读写

因为在读取中几乎没有任何抽象层(领域模型就是这样的一层),单元测试在那里没有任何用处。如果您决定测试您的读取,请在真实数据库上使用集成测试。

Because there are hardly any abstraction layers in reads (the domain model is one such layer), unit tests aren’t of any use there. If you decide to test your reads, do that using integration tests on a real database.

10.5.2 你应该测试存储库吗?

10.5.2  Should you test repositories?

存储库在数据库之上提供了有用的抽象。这是我们示例 CRM 项目中的一个使用示例:

Repositories provide a useful abstraction on top of the database. Here’s a usage example from our sample CRM project:

用户 user = _userRepository.GetUserById(userId);
_userRepository.SaveUser(用户);
User user = _userRepository.GetUserById(userId);
_userRepository.SaveUser(user);

您应该独立于其他集成测试来测试存储库吗?测试存储库如何将域对象映射到数据库似乎是有益的。毕竟,此功能中存在相当大的错误空间。尽管如此,由于高昂的维护成本和较差的回归保护,此类测试对您的测试套件来说是一种净损失。让我们更详细地讨论这两个缺点。

Should you test repositories independently of other integration tests? It might seem beneficial to test how repositories map domain objects to the database. After all, there’s quite significant room for a mistake in this functionality. Still, such tests are a net loss to your test suite due to high maintenance costs and inferior protection against regressions. Let’s discuss these two drawbacks in more detail.

维护成本高

High maintenance costs

存储库属于第 7 章代码图类型的控制器象限(图10.8)。它们表现出很小的复杂性并与进程外依赖项进行通信:数据库。进程外依赖性的存在会增加测试的维护成本。

Repositories fall into the controllers quadrant on the types of code diagram from chapter 7 (figure 10.8). They exhibit little complexity and communicate with an out-of-process dependency: the database. The presence of that out-of-process dependency is what inflates the tests' maintenance costs.

图 10.8。存储库表现出很小的复杂性并与进程外依赖项进行通信,因此属于代码图类型的控制器象限。

Figure 10.8. Repositories exhibit little complexity and communicate with the out-of-process dependency, thus falling into the controllers quadrant on the types of code diagram.

CH10 图 8 存储库

在维护成本方面,测试存储库承担着与常规集成测试相同的负担。但是这样的测试是否提供了同等数量的回报?不幸的是,事实并非如此。

When it comes to maintenance costs, testing repositories carries the same burden as regular integration tests. But does such testing provide an equal amount of benefits in return? Unfortunately, it doesn’t.

对倒退的保护较差

Inferior protection against regressions

存储库没有那么复杂,并且在防止回归方面的许多收益与常规集成测试提供的收益重叠。因此,对存储库的测试不会增加足够重要的价值。

Repositories don’t carry that much complexity, and a lot of gains in protection against regressions overlap with the gains provided by regular integration tests. Thus, tests on repositories don’t add significant enough value.

测试存储库的最佳做法是将它的小复杂性提取到一个独立的算法中,并专门测试该算法。这就是前面章节中的内容UserFactoryCompanyFactory目的。这两个类执行所有映射,而无需任何协作者、进程外或其他方式。存储库(Database类)仅包含简单的 SQL 查询。

The best course of action in testing a repository is to extract the little complexity it has into a self-contained algorithm and test that algorithm exclusively. That’s what UserFactory and CompanyFactory were for in earlier chapters. These two classes performed all the mappings without taking on any collaborators, out-of-process or otherwise. The repositories (the Database class) only contained simple SQL queries.

Database不幸的是,在使用 ORM 时,数据映射(以前由工厂执行)和与数据库的交互(以前由 执行)之间的这种分离是不可能的。您不能在不调用数据库的情况下测试您的 ORM 映射,至少不能在不影响重构阻力的情况下进行。因此,请遵循以下准则:不要直接测试存储库,仅作为总体集成测试套件的一部分。

Unfortunately, such a separation between data mapping (formerly performed by the factories) and interactions with the database (formerly performed by Database) is impossible when using an ORM. You can’t test your ORM mappings without calling the database, at least not without compromising resistance to refactoring. Therefore, adhere to the following guideline: don’t test repositories directly, only as part of the overarching integration test suite.

也不要EventDispatcher单独测试(此类将域事件转换为对非托管依赖项的调用)。以维护复杂的模拟机器所需的高昂成本换取防止回归的收益太少。

Don’t test EventDispatcher separately either (this class converts domain events into calls to unmanaged dependencies). There are too little gains in protection against regressions in exchange for the too high costs required to maintain the complicated mock machinery.

10.5.3 结论

10.5.3  Conclusion

针对数据库精心设计的测试提供了针对错误的防弹保护。根据我的经验,它们是最有效的工具之一,没有它们就不可能对您的软件充满信心。当您重构数据库、切换 ORM 或更改数据库供应商时,此类测试会有很大帮助。

Well-crafted tests against the database provide bullet-proof protection from bugs. In my experience, they are one of the most effective tools without which it’s impossible to gain full confidence in your software. Such tests help enormously when you refactor the database, switch the ORM, or change the database vendor.

事实上,我们的示例项目在本章前面已经过渡到 Entity Framework ORM,我只需要在集成测试中修改几行代码即可确保过渡成功。直接与托管依赖项一起工作的集成测试是防止大规模重构导致错误的最有效方法。

In fact, our sample project transitioned to the Entity Framework ORM earlier in this chapter, and I only needed to modify a couple lines of code in the integration test to make sure the transition was successful. Integration tests working directly with managed dependencies are the most efficient way to protect against bugs resulting from large-scale refactorings.

10.6 总结

10.6  Summary

  • 将数据库架构与您的源代码一起存储在源代码控制系统中。数据库模式由表、视图、索引、存储过程以及构成数据库构建蓝图的任何其他内容组成。

  • Store database schema in a source control system, along with your source code. Database schema consists of tables, views, indexes, stored procedures, and anything else that comprises a blueprint of how the database is constructed.

  • 参考数据也是数据库模式的一部分。为了使应用程序正常运行,必须预先填充这些数据。要区分参考数据和常规数据,请查看您的应用程序是否可以修改该数据。如果是,则为常规数据;否则,它是参考数据。

  • Reference data is also part of the database schema. It is data that must be pre-populated in order for the application to operate properly. To differentiate between reference and regular data, look at whether your application can modify that data. If so, it’s regular data; otherwise, it’s reference data.

  • 为每个开发人员提供一个单独的数据库实例。更好的是,将该实例托管在开发人员自己的机器上以获得最大的测试执行速度。

  • Have a separate database instance for every developer. Better yet, host that instance on the developer’s own machine for maximum test execution speed.

  • 基于状态的数据库交付方法使状态明确,并让比较工具隐式控制迁移。基于迁移的方法强调使用将数据库从一种状态转换到另一种状态的显式迁移。数据库状态的明确性使其更容易处理合并冲突,而明确的迁移有助于解决数据移动问题

  • The state-based approach to database delivery makes the state explicit and lets a comparison tool implicitly control migrations. The migration-based approach emphasizes the use of explicit migrations that transition the database from one state to another. The explicitness of the database state makes it easier to handle merge conflicts, while explicit migrations help tackle data motion.

  • 更喜欢基于迁移的方法而不是基于状态的方法,因为处理数据移动比合并冲突重要得多。通过迁移将每个修改应用到数据库模式(包括参考数据)。

  • Prefer the migration-based approach over state-based because handling data motion is much more important than merge conflicts. Apply every modification to the database schema (including reference data) through migrations.

  • 业务操作必须以原子方式更新数据。要实现原子性,依赖于底层数据库的事务机制。

  • Business operations must update data atomically. To achieve atomicity, rely on the underlying database’s transaction mechanism.

  • 尽可能使用工作单元模式。一个工作单元也依赖于底层数据库的事务;它还将所有更新推迟到业务运营结束,从而提高性能。

  • Use the unit of work pattern when possible. A unit of work relies on the underlying database’s transactions too; it also defers all updates to the end of the business operation, thus improving performance.

  • 不要在测试的各个部分之间重复使用数据库事务或工作单元。每个 arrange、act 和 assert 部分都应该有自己的事务或工作单元。

  • Don’t reuse database transactions or units of work between sections of the test. Each arrange, act, and assert section should have its own transaction or unit of work.

  • 按顺序执行集成测试。并行执行需要付出巨大的努力,通常不值得。

  • Execute integration tests sequentially. Parallel execution involves significant effort and usually is not worth it.

  • 在测试开始时清理剩余数据。这种方法运行速度很快,不会导致不一致的行为,并且不容易意外跳过清理阶段。使用这种方法,您也不必引入单独的拆卸阶段。

  • Clean up leftover data at the start of a test. This approach works fast, doesn’t result in inconsistent behavior, and isn’t prone to accidentally skipping the cleanup phase. With this approach, you don’t have to introduce a separate teardown phase either.

  • 避免使用内存数据库,例如 SQLite。如果您的测试针对不同供应商的数据库运行,您将永远无法获得良好的保护。在测试中使用与生产中相同的数据库管理系统。

  • Avoid in-memory databases such as SQLite. You’ll never gain good protection if your tests run against a database of a different vendor. Use the same database management system in tests as in production.

  • 通过将非必要部分提取到私有方法或辅助类中来缩短测试:

    • 对于排列部分,选择 Object Mother 而不是 Test Data Builder。

    • 对于行为,创建装饰器方法。

    • 对于断言,引入流畅的接口。

  • Shorten tests by extracting non-essential parts into private methods or helper classes:

    • For the arrange section, choose Object Mother over Test Data Builder.

    • For act, create decorator methods.

    • For assert, introduce fluent interface.

  • 测试读取的阈值应高于写入的阈值。只测试最复杂或最重要的读取操作;忽略其余部分。

  • The threshold for testing reads should be higher than that for writes. Test only the most complex or important read operations; disregard the rest.

  • 不要直接测试存储库,仅作为总体集成测试套件的一部分。对存储库的测试引入了过多的维护成本,而在防止回归方面的额外收益却很少。

  • Don’t test repositories directly, only as part of the overarching integration test suite. Tests on repositories introduce too much maintenance costs for too little additional gains in protection against regressions.

第 4 部分

Part 4

单元测试反模式

Unit testing anti-patterns

本书的第四部分也是最后一部分涵盖了常见的单元测试反模式。您过去很可能遇到过其中一些。尽管如此,使用第 4 章中定义的良好单元测试的四个属性来研究这个主题还是很有趣的。您可以使用这些属性来分析任何单元测试概念或模式。反模式也不例外。

This fourth and final part of the book covers common unit testing anti-patterns. You’ve most likely encountered some of them in the past. Still, it’s interesting to look at this topic using the four attributes of a good unit test defined in chapter 4. You can use those attributes to analyze any unit testing concepts or patterns. Anti-patterns aren’t an exception.

11

11

单元测试反模式

Unit testing anti-patterns

本章涵盖:

This chapter covers:

  • 单元测试私有方法

  • Unit testing private methods

  • 公开私有状态以启用单元测试

  • Exposing private state to enable unit testing

  • 将领域知识泄露给测试

  • Leaking domain knowledge to tests

  • 模拟具体类

  • Mocking concrete classes

本章汇集了一些不太相关的主题(主要是反模式),这些主题不适合放在本书的前面,最好单独使用。反模式是针对反复出现的问题的常见解决方案,这些问题表面上看起来很合适,但会导致进一步的问题。

This chapter is an aggregation of lesser related topics (mostly anti-patterns) that didn’t fit in earlier in the book and are better served on their own. An anti-pattern is a common solution to a recurring problem that looks appropriate on the surface but leads to problems further down the road.

您将学习如何在测试中处理时间,如何识别和避免诸如私有方法单元测试、代码污染、模拟具体类等反模式。这些主题中的大多数都遵循第 2 部分中描述的首要原则。不过,它们非常值得明确说明。您过去可能至少听说过这些反模式中的一些,但是本章将帮助您将这些点联系起来,可以这么说,并了解它们所基于的基础。

You will learn how to work with time in tests, how to identify and avoid such anti-patterns as unit testing of private methods, code pollution, mocking concrete classes, and more. Most of these topics follow from the first principles described in part 2. Still, they are well worth spelling out explicitly. You’ve probably heard of at least some of these anti-patterns in the past, but this chapter will help you connect the dots, so to speak, and see the foundations they are based upon.

11.1 单元测试私有方法

11.1  Unit testing private methods

说到单元测试,最常被问到的问题之一就是如何测试私有方法?简短的回答是你根本不应该这样做,但是这个话题有很多细微差别。

When it comes to unit testing, one of the most commonly asked questions is how to test a private method? The short answer is you shouldn’t do that at all, but there’s quite a bit of nuance to this topic.

11.1.1 私有方法和测试脆弱性

11.1.1  Private methods and test fragility

为了启用单元测试而公开您本应保持私有的方法违反了我们在第 5 章中讨论的基本原则之一:仅测试可观察的行为。公开私有方法会导致测试与实现细节耦合,并最终破坏测试对重构的抵抗力——重构是四个指标中最重要的指标。(再一次,所有四个指标都是防止回归、抵抗重构、快速反馈和可维护性。)与其直接测试私有方法,不如间接测试它们,作为总体可观察行为的一部分。

Exposing methods that you would otherwise keep private just to enable unit testing violates one of the foundational principles we discussed in chapter 5: testing observable behavior only. Exposing private methods leads to coupling tests to implementation details and, ultimately, damaging your tests' resistance to refactoring—the most important metric of the four. (All four metrics, once again, are protection against regressions, resistance to refactoring, fast feedback, and maintainability.) Instead of testing private methods directly, test them indirectly, as part of the overarching observable behavior.

11.1.2 私有方法和覆盖不足

11.1.2  Private methods and insufficient coverage

有时,私有方法过于复杂,将其作为可观察行为的一部分进行测试并不能提供足够的覆盖率。假设可观察行为已经具有合理的测试覆盖率,则可能有两个问题在起作用:

Sometimes, the private method is too complex, and testing it as part of the observable behavior doesn’t provide sufficient coverage. Assuming the observable behavior already has reasonable test coverage, there can be two issues at play:

  • 这是死代码。--如果没有使用未覆盖的代码,这可能是重构后留下的一些无关代码。最好删除此代码。

  • This is dead code.--If the uncovered code isn’t being used, this is likely some extraneous code left after a refactoring. It’s best to delete this code.

  • 缺少抽象。--如果私有方法太复杂(因此很难通过类的公共 API 进行测试),则表示缺少抽象,应将其提取到单独的类中。

  • There’s a missing abstraction.--If the private method is too complex (and thus is hard to test via the class’s public API), it’s an indication of a missing abstraction that should be extracted into a separate class.

让我们用一个例子来说明第二个问题。

Let’s illustrate the second issue with an example.

清单 11.1。具有复杂私有方法的类

Listing 11.1. A class with a complex private method

公共课秩序
{
    私人客户_客户;
    私人清单<产品> _产品;

    公共字符串 GenerateDescription()
    {
        return $"客户姓名:{_customer.Name}, " +
            $"商品总数:{_products.Count}," +
            $"总价:{GetPrice()}";   
    }

    私有小数 GetPrice()   
    {
        decimal basePrice = /* 根据_products计算*/;
        小数折扣 = /* 根据 _customer 计算 */;
        decimal taxes = /* Calculate based on _products */;
        返回 basePrice - 折扣 + 税;
    }
}
public class Order
{
    private Customer _customer;
    private List<Product> _products;

    public string GenerateDescription()
    {
        return $"Customer name: {_customer.Name}, " +
            $"total number of products: {_products.Count}, " +
            $"total price: {GetPrice()}";   
    }

    private decimal GetPrice()   
    {
        decimal basePrice = /* Calculate based on _products */;
        decimal discounts = /* Calculate based on _customer */;
        decimal taxes = /* Calculate based on _products */;
        return basePrice - discounts + taxes;
    }
}

复杂私有方法

Complex private method

复杂的私有方法由更简单的公共方法使用。

The complex private method is used by a much simpler public method.

在清单9.1中,GenerateDescription()方法非常简单:它返回订单的一般描述。但它使用 private GetPrice(),这要复杂得多。它包含重要的业务逻辑,需要进行彻底的测试。这种逻辑是一种缺失的抽象。不公开该GetPrice方法,而是通过将其提取到一个单独的类中来使该抽象显式化,如下面的清单所示。

In listing 9.1, the GenerateDescription() method is quite simple: it returns a generic description of the order. But it uses the private GetPrice(), which is much more complex. It contains important business logic and needs to be thoroughly tested. That logic is a missing abstraction. Instead of exposing the GetPrice method, make this abstraction explicit by extracting it into a separate class as shown in the following listing.

清单 11.2。将复杂的私有方法提取到一个单独的类中

Listing 11.2. Extracting the complex private method into a separate class

公共课秩序
{
    私人客户_客户;
    私人清单<产品> _产品;

    公共字符串 GenerateDescription()
    {
        var calc = new 价格计算器();

        return $"客户姓名:{_customer.Name}, " +
            $"商品总数:{_products.Count}," +
            $"总价:{calc.Calculate(_customer, _products)}";
    }
}

公共课价格计算器
{
    public decimal Calculate(Customer customer, List<Product> 产品)
    {
        decimal basePrice = /* 根据产品计算 */;
        小数折扣 = /* 根据客户计算 */;
        decimal taxes = /* 根据产品计算 */;
        返回 basePrice - 折扣 + 税;
    }
}
public class Order
{
    private Customer _customer;
    private List<Product> _products;

    public string GenerateDescription()
    {
        var calc = new PriceCalculator();

        return $"Customer name: {_customer.Name}, " +
            $"total number of products: {_products.Count}, " +
            $"total price: {calc.Calculate(_customer, _products)}";
    }
}

public class PriceCalculator
{
    public decimal Calculate(Customer customer, List<Product> products)
    {
        decimal basePrice = /* Calculate based on products */;
        decimal discounts = /* Calculate based on customer */;
        decimal taxes = /* Calculate based on products */;
        return basePrice - discounts + taxes;
    }
}

现在您可以PriceCalculator独立测试Order. 您还可以使用基于输出(功能)的单元测试样式,因为它PriceCalculator没有任何隐藏的输入或输出。有关单元测试样式的更多信息,请参见第 6 章。

Now you can test PriceCalculator independently of Order. You can also use the output-based (functional) style of unit testing because PriceCalculator doesn’t have any hidden inputs or outputs. See chapter 6 for more information about styles of unit testing.

11.1.3 何时测试私有方法是可接受的

11.1.3  When testing private methods is acceptable

从不测试私有方法的规则也有例外。要了解这些异常,我们需要重新审视第 5 章中代码的公开性和用途之间的关系。表11.1总结了这种关系(您已经在第 5 章中看到过该表;为了方便,我将其复制到此处)。

There are exceptions to the rule of never testing private methods. To understand those exceptions, we need to revisit the relationship between the code’s publicity and purpose from chapter 5. Table 11.1 sums up that relationship (you already saw this table in chapter 5; I’m copying it here for convenience).

表 11.1。代码的公开性和目的之间的关系。

Table 11.1. The relationship between the code’s publicity and purpose.

  可观察到的行为 实施细节

民众

Public

好的

Good

坏的

Bad

私人的

Private

不适用

N/A

好的

Good

您可能还记得第 5 章,将可观察行为公开并将实现细节设为私有会产生设计良好的 API。相反,泄漏实现细节会破坏代码的封装性。可观察行为和私有方法的交集被标记为N/A因为要使方法成为可观察行为的一部分,它必须由客户端代码使用,如果该方法是私有的,这是不可能的。

As you might remember from chapter 5, making the observable behavior public and implementation details private results in a well-designed API. On the contrary, leaking implementation details damages the code’s encapsulation. The intersection of observable behavior and private methods is marked as N/A because for a method to become part of observable behavior, it has to be used by the client code, which is impossible if that method is private.

请注意,测试私有方法本身并不坏。这只是不好的,因为那些私有方法是实现细节的代理。测试实现细节是最终导致测试脆弱性的原因。尽管如此,在极少数情况下,方法既是私有的又是可观察行为的一部分(因此N/A11.1中的标记并不完全正确)。

Note that testing private methods isn’t bad in and of itself. It’s only bad because those private methods are a proxy for implementation details. Testing implementation details is what ultimately leads to test brittleness. Having that said, there are rare cases where a method is both private and part of observable behavior (and thus the N/A marking in table 11.1 isn’t entirely correct).

我们以管理征信查询的系统为例。每天一次将新查询直接批量加载到数据库中。管理员然后逐一审查这些查询并决定是否批准它们。以下是该类Inquiry在该系统中的外观:

Let’s take a system that manages credit inquiries as an example. New inquiries are bulk loaded directly into the database once a day. Administrators then review those inquiries one by one and decide whether to approve them. Here’s how the Inquiry class might look in that system:

清单 11.3。具有私有构造函数的类

Listing 11.3. A class with a private constructor

公开课查询
{
    公共布尔 IsApproved { 得到; 私有集;}
    公共日期时间?批准时间{ 得到; 私有集;}

    私讯(                                
        bool isApproved, DateTime? 时间批准)   
    {
        如果(isApproved && !timeApproved.HasValue)
            抛出新的异常();

        已批准 = 已批准;
        批准时间 = 批准时间;
    }

    公共无效批准(日期时间现在)
    {
        如果(已批准)
            返回;

        已批准 = 真;
        批准时间 = 现在;
    }
}
public class Inquiry
{
    public bool IsApproved { get; private set; }
    public DateTime? TimeApproved { get; private set; }

    private Inquiry(                               
        bool isApproved, DateTime? timeApproved)   
    {
        if (isApproved && !timeApproved.HasValue)
            throw new Exception();

        IsApproved = isApproved;
        TimeApproved = timeApproved;
    }

    public void Approve(DateTime now)
    {
        if (IsApproved)
            return;

        IsApproved = true;
        TimeApproved = now;
    }
}

私有构造函数

Private constructor

注意清单9.3中的私有构造函数。它是私有的,因为该类是通过对象关系映射 (ORM) 库从数据库中恢复的。该 ORM 不需要公共构造函数;它很可能与私人合作。同时,我们的系统也不需要构造函数,因为它不负责创建这些查询。

Notice the private constructor in listing 9.3. It is private because the class is restored from the database by an object-relational mapping (ORM) library. That ORM doesn’t need a public constructor; it may well work with a private one. At the same time, our system doesn’t need a constructor either because it’s not responsible for the creation of those inquiries.

Inquiry鉴于您无法实例化其对象,您如何测试该类?一方面,批准逻辑显然很重要,因此应该进行单元测试。但另一方面,公开构造函数会违反不公开私有方法的规则。

How do you test the Inquiry class given that you can’t instantiate its objects? On the one hand, the approval logic is clearly important and thus should be unit tested. But on the other, making the constructor public would violate the rule of not exposing private methods.

Inquiry的构造函数是一个方法的示例,它既是私有的又是可观察行为的一部分。这个构造函数实现了与 ORM 的契约,它是私有的这一事实并没有降低该契约的重要性:没有它,ORM 将无法从数据库中恢复查询。

Inquiry's constructor is an example of a method that is both private and part of the observable behavior. This constructor fulfills the contract with the ORM, and the fact that it’s private doesn’t make that contract less important: the ORM wouldn’t be able to restore inquiries from the database without it.

因此,Inquiry在这种特殊情况下,将 的构造函数公开不会导致测试脆弱性。事实上,它可以说会使类的 API 更接近于设计良好。只需确保构造函数包含维护其封装所需的所有先决条件。在清单9.3中,这样的前提条件是要求在所有已批准的查询中都有批准时间。

And so, making Inquiry's constructor public won’t lead to test brittleness in this particular case. In fact, it will arguably bring the class’s API closer to being well-designed. Just make sure the constructor contains all the preconditions required to maintain its encapsulation. In listing 9.3, such a precondition is the requirement to have the approval time in all approved inquiries.

或者,如果您希望使类的公共 API 表面尽可能小,则可以Inquiry在测试中通过反射进行实例化。虽然这看起来像是 hack,但您只是在关注 ORM,它也在幕后使用了反射。

Alternatively, if you prefer to keep the class’s public API surface as small as possible, you can instantiate Inquiry via reflection in tests. Although this looks like a hack, you are just following the ORM, which also uses reflection behind the scenes.

11.2 暴露私有状态

11.2  Exposing private state

另一种常见的反模式是为了单元测试的唯一目的而暴露私有状态。这里的指导原则与私有方法相同:不要公开您本来会保持私有的状态——仅测试可观察到的行为。让我们看看下面的清单。

Another common anti-pattern is exposing private state for the sole purpose of unit testing. The guideline here is the same as with private methods: don’t expose state that you would otherwise keep private—test observable behavior only. Let’s take a look at the following listing.

清单 11.4。具有私有状态的类

Listing 11.4. A class with private state

公共课客户
{
    私人客户状态 _status =    
        CustomerStatus.Regular;        

    公共无效促进()
    {
        _status = CustomerStatus.Preferred;
    }

    公共十进制 GetDiscount()
    {
        返回 _status == CustomerStatus.Preferred ?0.05米:0米;
    }
}

公共枚举 CustomerStatus
{
    常规的,
    首选
}
public class Customer
{
    private CustomerStatus _status =   
        CustomerStatus.Regular;        

    public void Promote()
    {
        _status = CustomerStatus.Preferred;
    }

    public decimal GetDiscount()
    {
        return _status == CustomerStatus.Preferred ? 0.05m : 0m;
    }
}

public enum CustomerStatus
{
    Regular,
    Preferred
}

私人国家

Private state

清单6.4显示了一个Customer类。每个客户都在状态中创建Regular,然后可以提升为Preferred;届时,他们可以在所有商品上享受 5% 的折扣。

Listing 6.4 shows a Customer class. Each customer is created in the Regular status and then can be promoted to Preferred; at which point, they get a 5% discount on everything.

您将如何测试该Promote()方法?此方法的副作用是更改字段_status,但字段本身是私有的,因此在测试中不可用。一个诱人的解决方案是将此字段公开。毕竟,状态的改变不是调用的最终目的吗Promote()

How would you test the Promote() method? This method’s side effect is a change of the _status field, but the field itself is private and thus not available in tests. A tempting solution would be to just make this field public. After all, isn’t the change of status the ultimate goal of calling Promote()?

然而,这将是一种反模式。请记住,您的测试应该以与生产代码完全相同的方式与被测系统 (SUT) 交互,并且不应具有任何特殊权限。在清单6.4中,该_status字段在生产代码中是隐藏的,因此不是 SUT 可观察行为的一部分。公开该字段将导致耦合测试与实现细节。Promote()那怎么测试呢?

That would be an anti-pattern, however. Remember, your tests should interact with the system under test (SUT) exactly the same way as the production code and shouldn’t have any special privileges. In listing 6.4, the _status field is hidden from the production code and thus is not part of the SUT’s observable behavior. Exposing that field would result in coupling tests to implementation details. How to test Promote() then?

相反,您应该做的是查看生产代码如何使用此类。在此特定示例中,生产代码不关心客户的状态;否则,该字段将是公开的。生产代码唯一关心的信息是客户在促销后获得的折扣。因此,这也是您需要在测试中验证的内容。您需要检查:

What you should do, instead, is look at how the production code uses this class. In this particular example, the production code doesn’t care about the customer’s status; otherwise, that field would be public. The only information the production code does care about is the discount the customer gets after the promotion. And so that’s what you need to verify in tests too. You need to check that:

  • 新创建的客户没有折扣。

  • A newly created customer has no discount.

  • 一旦客户被提升,折扣就变为 5%。

  • Once the customer is promoted, the discount becomes 5%.

稍后,如果生产代码开始使用客户状态字段,您也可以在测试中耦合到该字段,因为它将正式成为 SUT 可观察行为的一部分。

Later, if the production code starts using the customer status field, you’d be able to couple to that field in tests too because it would officially become part of the SUT’s observable behavior.

V03 为了可测试性而扩大公共 API 表面是一种不好的做法。

V03Widening the public API surface for the sake of testability is a bad practice.

11.3 将领域知识泄露给测试

11.3  Leaking domain knowledge to tests

将领域知识泄露给测试是另一种非常常见的反模式。它通常发生在涵盖复杂算法的测试中。我们以下面的(诚然,并不复杂)计算算法为例:

Leaking domain knowledge to tests is another quite common anti-pattern. It usually takes place in tests that cover complex algorithms. Let’s take the following (admittedly, not that complex) calculation algorithm as an example:

公共静态类计算器
{
    public static int Add(int value1, int value2)
    {
        返回值 1 + 值 2;
    }
}
public static class Calculator
{
    public static int Add(int value1, int value2)
    {
        return value1 + value2;
    }
}

此清单显示了一种不正确的测试方法:

This listing shows an incorrect way to test it:

清单 11.5。泄漏算法实现

Listing 11.5. Leaking algorithm implementation

公共课计算器测试
{
    [事实]
    公共无效 Adding_two_numbers()
    {
        整数值 1 = 1;
        整数值 2 = 3;
        int expected = value1 + value2;   

        int actual = Calculator.Add(value1, value2);

        断言。等于(预期,实际);
    }
}
public class CalculatorTests
{
    [Fact]
    public void Adding_two_numbers()
    {
        int value1 = 1;
        int value2 = 3;
        int expected = value1 + value2;   

        int actual = Calculator.Add(value1, value2);

        Assert.Equal(expected, actual);
    }
}

泄漏

The leakage

您还可以参数化测试以抛出更多测试用例,而几乎没有额外成本,如下一个清单所示。

You could also parameterize the test to throw a couple more test cases at almost no additional cost, as shown in the next listing.

清单 11.6。同一测试的参数化版本

Listing 11.6. A parameterized version of the same test

公共课计算器测试
{
    [理论]
    [内联数据(1, 3)]
    [内联数据(11, 33)]
    [内联数据(100, 500)]
    public void Adding_two_numbers(int value1, int value2)
    {
        int expected = value1 + value2;   

        int actual = Calculator.Add(value1, value2);

        断言。等于(预期,实际);
    }
}
public class CalculatorTests
{
    [Theory]
    [InlineData(1, 3)]
    [InlineData(11, 33)]
    [InlineData(100, 500)]
    public void Adding_two_numbers(int value1, int value2)
    {
        int expected = value1 + value2;   

        int actual = Calculator.Add(value1, value2);

        Assert.Equal(expected, actual);
    }
}

泄漏

The leakage

清单6.56.6初看起来不错,但实际上它们是反模式的示例:这些测试从生​​产代码中复制了算法实现。当然,这可能看起来没什么大不了的。毕竟,它只是一根线。但这只是因为示例本身相当简单。我见过涵盖相当复杂算法的测试,除了在 arrange 部分重新实现这些算法外什么也没做。它们基本上是生产代码的复制粘贴。

Listings 6.5 and 6.6 look fine at first, but they are, in fact, examples of the anti-pattern: these tests duplicate the algorithm implementation from the production code. Of course, it might not seem like a big deal. After all, it’s just one line. But that’s only because the example itself is rather simplified. I’ve seen tests that covered quite complex algorithms and did nothing but re-implemented those algorithms in the arrange part. They were basically a copy-paste from the production code.

这些测试是耦合到实现细节的另一个例子。他们在抵制重构的指标上得分几乎为零,因此毫无价值。这样的测试没有机会区分合法的失败和误报。如果算法的变化导致这些测试失败,团队很可能只是将该算法的新版本复制到测试中,甚至没有尝试找出根本原因(这是可以理解的,因为测试只是算法的重复第一名)。

These tests are another example of coupling to implementation details. They score almost zero on the metric of resisting to refactoring and are worthless as a result. Such tests don’t have a chance at differentiating legitimate failures from false positives. Should a change in the algorithm make those tests fail, the team would most likely just copy the new version of that algorithm to the test without even trying to identify the root cause (which is understandable because the tests were a mere duplication of the algorithm in the first place).

那么如何正确地测试算法呢?编写测试时不要暗示任何特定的实现。不要像这个清单所示那样复制算法,而是将其结果硬编码到测试中。

How to test the algorithm properly then? Don’t imply any specific implementation when writing tests. Instead of duplicating the algorithm like this listing shows, hard-code its results into the test.

清单 11.7。没有领域知识的测试

Listing 11.7. Test with no domain knowledge

公共课计算器测试
{
    [理论]
    [内联数据(1, 3, 4 )]
    [内联数据(11, 33, 44 )]
    [内联数据(100, 500, 600 )]
    public void Adding_two_numbers(int value1, int value2, int expected )
    {
        int actual = Calculator.Add(value1, value2);
        断言。等于(预期,实际);
    }
}
public class CalculatorTests
{
    [Theory]
    [InlineData(1, 3, 4)]
    [InlineData(11, 33, 44)]
    [InlineData(100, 500, 600)]
    public void Adding_two_numbers(int value1, int value2, int expected)
    {
        int actual = Calculator.Add(value1, value2);
        Assert.Equal(expected, actual);
    }
}

乍一看似乎有悖常理,但在单元测试方面,对预期结果进行硬编码是一种很好的做法。硬编码值的重要部分是使用 SUT 本身以外的东西预先计算它们,最好是在领域专家的帮助下。当然,前提是算法足够复杂(我们都是两个数字相加的专家)。或者,如果您重构遗留应用程序,您可以让遗留代码产生这些结果,然后将它们用作测试中的预期值。

It can seem counter-intuitive at first, but hard-coding the expected result is a good practice when it comes to unit testing. The important part with the hard-coded values is to have them pre-calculated using something other than the SUT itself, ideally with the help of a domain expert. Of course, that’s only if the algorithm is complex enough (we are all experts at summing up two numbers). Alternatively, if you refactor a legacy application, you can have the legacy code produce those results and then use them as expected values in tests.

11.4 代码污染

11.4  Code pollution

下一个反模式是代码污染。

The next anti-pattern is code pollution.

定义 11.1:

Definition 11.1:

代码污染是添加仅用于测试的生产代码。

Code pollution is adding production code that’s only needed for testing.

代码污染通常表现为各种类型的开关。我们以记录器为例:

Code pollution often takes the form of various types of switches. Let’s take a logger as an example:

清单 11.8。带有布尔开关的记录器

Listing 11.8. Logger with a boolean switch

公共类记录器
{
    私有只读布尔 _isTestEnvironment;

    公共记录器(bool isTestEnvironment)   
    {
        _isTestEnvironment = isTestEnvironment;
    }

    公共无效日志(字符串文本)
    {
        如果(_isTestEnvironment)   
            返回;

        /* 记录文本 */
    }
}

公共类控制器
{
    public void SomeMethod(记录器记录器)
    {
        logger.Log("调用了 SomeMethod");
    }
}
public class Logger
{
    private readonly bool _isTestEnvironment;

    public Logger(bool isTestEnvironment)   
    {
        _isTestEnvironment = isTestEnvironment;
    }

    public void Log(string text)
    {
        if (_isTestEnvironment)   
            return;

        /* Log the text */
    }
}

public class Controller
{
    public void SomeMethod(Logger logger)
    {
        logger.Log("SomeMethod is called");
    }
}

开关

The switch

在清单10.11中,Logger有一个构造函数参数指示该类是否在生产环境中运行。如果是,则记录器将消息记录到文件中;否则,它什么都不做。使用这样的布尔开关,您可以在测试运行期间禁用记录器。

In listing 10.11, Logger has a constructor parameter that indicates whether the class runs in production. If so, the logger records the message into the file; otherwise, it does nothing. With such a boolean switch, you can disable the logger during test runs.

清单 11.9。使用布尔开关的测试

Listing 11.9. A test using the boolean switch

[事实]
公共无效 Some_test()
{
    var logger = new Logger(true);   
    var sut = new Controller();

    sut.SomeMethod(记录器);

    /* 断言 */
}
[Fact]
public void Some_test()
{
    var logger = new Logger(true);   
    var sut = new Controller();

    sut.SomeMethod(logger);

    /* assert */
}

设置参数为true表示测试环境

Sets the parameter to true to indicate the test environment

代码污染的问题在于它混淆了测试代码和生产代码,从而增加了后者的维护成本。为避免这种反模式,请将测试代码保留在生产代码库之外。

The problem with code pollution is that it mixes up test and production code and thereby increases the maintenance costs of the latter. To avoid this anti-pattern, keep the test code out of the production code base.

在带有 的示例中Logger,引入一个ILogger接口并创建它的两个实现:一个用于生产的真实实现和一个用于测试目的的假实现。之后,重新定位Controller以接受接口而不是具体类(清单9.11)。

In the example with Logger, introduce an ILogger interface and create two implementations of it: a real one for production and a fake one for testing purposes. After that, re-target Controller to accept the interface instead of the concrete class (listing 9.11).

清单 11.10。没有开关的版本

Listing 11.10. A version without the switch

公共接口 ILogger
{
    无效日志(字符串文本);
}

公共类记录器:ILogger        
{                                    
    public void Log(字符串文本)     
    {                                
        /* 记录文本 */           
    }                                
}                                   

公共类 FakeLogger:ILogger    
{                                    
    public void Log(字符串文本)     
    {                                
        /* 什么都不做 */             
    }                                
}                                   

公共类控制器
{
    公共无效 SomeMethod(ILogger 记录器)
    {
        logger.Log("调用了 SomeMethod");
    }
}
public interface ILogger
{
    void Log(string text);
}

public class Logger : ILogger       
{                                   
    public void Log(string text)    
    {                               
        /* Log the text */          
    }                               
}                                   

public class FakeLogger : ILogger   
{                                   
    public void Log(string text)    
    {                               
        /* Do nothing */            
    }                               
}                                   

public class Controller
{
    public void SomeMethod(ILogger logger)
    {
        logger.Log("SomeMethod is called");
    }
}

属于生产代码

Belongs in the production code

属于测试代码

Belongs in the test code

这种分离有助于使生产记录器保持简单,因为它不必再考虑不同的环境。

Such a separation helps keep the production logger simple because it doesn’t have to account for different environments anymore.

请注意,ILogger它本身也可以说是一种代码污染形式:它驻留在生产代码库中,但仅在测试时需要。那么新的实现如何更好呢?

Note that ILogger itself is arguably a form of code pollution too: it resides in the production code base but is only needed for testing. So how is the new implementation better?

引入的污染类型ILogger破坏性较小,也更容易处理。与初始Logger实现不同,在新版本中,您不会意外调用不适合生产使用的代码路径。你也不能在接口中有错误,因为它们只是没有代码的合同。与布尔开关相比,接口不会为潜在的错误引入额外的表面积。

The kind of pollution ILogger introduces is less damaging and easier to deal with. Unlike the initial Logger implementation, with the new version, you can’t accidentally invoke a code path that isn’t intended for production use. You can’t have bugs in interfaces either because they are just contracts with no code in them. In contrast to boolean switches, interfaces don’t introduce additional surface area for potential bugs.

11.5 模拟具体类

11.5  Mocking concrete classes

到目前为止,本书展示了使用接口的模拟示例,但还有一种替代方法:您可以改为模拟具体类,从而保留原始类的部分功能,这有时很有用。不过,这种替代方案有一个明显的缺点——它违反了单一职责原则。让我们看一下下面的清单来说明这个想法。

So far, this book showed mocking examples using interfaces, but there’s an alternative approach: you can mock concrete classes instead and thus preserve part of the original classes' functionality, which can be useful at times. This alternative has a significant drawback though—it violates the Single Responsibility principle. Let’s look at the following listing to illustrate this idea.

清单 11.11。一个计算统计的类

Listing 11.11. A class that calculates statistics

公开课统计计算器
{
    public (double totalWeight, double totalCost) 计算(
        int 客户 ID)
    {
        List<DeliveryRecord> 记录 = GetDeliveries(customerId);

        double totalWeight = records.Sum(x => x.Weight);
        double totalCost = records.Sum(x => x.Cost);

        返回(总重量,总成本);
    }

    公共列表<DeliveryRecord> GetDeliveries(int customerId)
    {
        /* 调用进程外依赖
        获取交货清单 */
    }
}
public class StatisticsCalculator
{
    public (double totalWeight, double totalCost) Calculate(
        int customerId)
    {
        List<DeliveryRecord> records = GetDeliveries(customerId);

        double totalWeight = records.Sum(x => x.Weight);
        double totalCost = records.Sum(x => x.Cost);

        return (totalWeight, totalCost);
    }

    public List<DeliveryRecord> GetDeliveries(int customerId)
    {
        /* Call an out-of-process dependency
        to get the list of deliveries */
    }
}

StatisticsCalculator收集并计算客户统计数据:发送给特定客户的所有货物的重量和成本。该类根据从外部服务(方法)检索到的交付列表进行计算GetDeliveries。我们还假设有一个控制器使用StatisticsCalculator如下清单所示。

StatisticsCalculator gathers and calculates customer statistics: the weight and cost of all deliveries sent to a particular customer. The class does the calculation based on the list of deliveries retrieved from an external service (the GetDeliveries methods). Let’s also say there’s a controller that uses StatisticsCalculator as shown in the following listing.

清单 11.12。控制器使用StatisticsCalculator

Listing 11.12. A controller using StatisticsCalculator

公共类 CustomerController
{
    私人只读统计计算器_计算器;

    公共客户控制器(统计计算器计算器)
    {
        _calculator = 计算器;
    }

    公共字符串 GetStatistics(int customerId)
    {
        (double totalWeight, double totalCost) = _calculator
            .计算(客户编号);

        返回
            $"运送总重量:{totalWeight}。" +
            $"总费用:{totalCost}";
    }
}
public class CustomerController
{
    private readonly StatisticsCalculator _calculator;

    public CustomerController(StatisticsCalculator calculator)
    {
        _calculator = calculator;
    }

    public string GetStatistics(int customerId)
    {
        (double totalWeight, double totalCost) = _calculator
            .Calculate(customerId);

        return
            $"Total weight delivered: {totalWeight}. " +
            $"Total cost: {totalCost}";
    }
}

您将如何测试该控制器?您不能为它提供一个真实的StatisticsCalculator实例,因为该实例引用了一个非托管的进程外依赖项。非托管依赖项必须替换为存根。同时,您StatisticsCalculator也不想完全替换。此类包含重要的计算功能,需要保持不变。

How would you test that controller? You can’t supply it with a real StatisticsCalculator instance because that instance refers to an unmanaged out-of-process dependency. The unmanaged dependency has to be substituted with a stub. At the same time, you don’t want to replace StatisticsCalculator entirely either. This class contains important calculation functionality, which needs to be left intact.

克服这种困境的一种方法是模拟StatisticsCalculator类本身并仅重写GetDeliveries()方法,这可以通过使该方法成为虚拟方法来实现。

One way to overcome this dilemma is to mock the StatisticsCalculator class itself and override only the GetDeliveries() method, which can be done by making that method virtual.

清单 11.13。模拟具体类的测试

Listing 11.13. Test that mocks the concrete class

[事实]
public void Customer_with_no_deliveries()
{
    // 安排
    var stub = new Mock<StatisticsCalculator> { CallBase = true };
    stub.Setup(x => x.GetDeliveries(1))   
        .Returns(new List<DeliveryRecord>());
    var sut = new CustomerController(stub.Object);

    // 行为
    字符串结果 = sut.GetStatistics(1);

    //断言
    Assert.Equal("交货总重量:0。总成本:0",result);
}
[Fact]
public void Customer_with_no_deliveries()
{
    // Arrange
    var stub = new Mock<StatisticsCalculator> { CallBase = true };
    stub.Setup(x => x.GetDeliveries(1))   
        .Returns(new List<DeliveryRecord>());
    var sut = new CustomerController(stub.Object);

    // Act
    string result = sut.GetStatistics(1);

    // Assert
    Assert.Equal("Total weight delivered: 0. Total cost: 0", result);
}

GetDeliveries() 必须是虚拟的。

GetDeliveries() must be made virtual.

这些CallBase = true设置告诉模拟保留基类行为,除非它被显式覆盖。使用这种方法,您可以只替换类的一部分,同时保持其余部分不变。正如我之前提到的,这是一种反模式。

The CallBase = true settings tells the mock to preserve the base class behavior unless it’s explicitly overridden. With this approach, you can substitute only a part of the class, while keeping the rest as is. As I mentioned earlier, this is an anti-pattern.

V03 必须模拟具体类以保留其部分功能是违反单一职责原则的结果。

V03The necessity to mock a concrete class in order to preserve part of its functionality is a result of violating the Single Responsibility principle.

StatisticsCalculator结合了两个不相关的职责:与非托管依赖项通信和计算统计信息。再看看清单10.14。方法Calculate()是领域逻辑所在的地方。GetDeliveries()只是收集该逻辑的输入。不用 mocking StatisticsCalculator,而是将这个类分成两部分,如下面的清单所示。

StatisticsCalculator combines two unrelated responsibilities: communicating with the unmanaged dependency and calculating statistics. Look at listing 10.14 again. The Calculate() method is where the domain logic lies. GetDeliveries() just gathers the inputs for that logic. Instead of mocking StatisticsCalculator, split this class in two as the following listing shows.

清单 11.14。分成StatisticsCalculator两类

Listing 11.14. Splitting StatisticsCalculator into two classes

公共类 DeliveryGateway : IDeliveryGateway
{
    公共列表<DeliveryRecord> GetDeliveries(int customerId)
    {
        /* 调用进程外依赖
        获取交货清单 */
    }
}

公开课统计计算器
{
    public (double totalWeight, double totalCost) 计算(
        列出<DeliveryRecord>记录)
    {
        double totalWeight = records.Sum(x => x.Weight);
        double totalCost = records.Sum(x => x.Cost);

        返回(总重量,总成本);
    }
}
public class DeliveryGateway : IDeliveryGateway
{
    public List<DeliveryRecord> GetDeliveries(int customerId)
    {
        /* Call an out-of-process dependency
        to get the list of deliveries */
    }
}

public class StatisticsCalculator
{
    public (double totalWeight, double totalCost) Calculate(
        List<DeliveryRecord> records)
    {
        double totalWeight = records.Sum(x => x.Weight);
        double totalCost = records.Sum(x => x.Cost);

        return (totalWeight, totalCost);
    }
}

下一个清单显示了重构后的控制器。

The next listing shows the controller after the refactoring.

清单 11.15。重构后的控制器

Listing 11.15. Controller after the refactoring

公共类 CustomerController
{
    私人只读统计计算器_计算器;
    私有只读 IDeliveryGateway _gateway;

    公共客户控制器(
        StatisticsCalculator计算器,    
        IDeliveryGateway 网关)          
    {
        _calculator = 计算器;
        _gateway = 网关;
    }

    公共字符串 GetStatistics(int customerId)
    {
        var 记录 = _gateway.GetDeliveries(customerId);
        (double totalWeight, double totalCost) = _calculator
            .计算(记录);

        返回
            $"运送总重量:{totalWeight}。" +
            $"总费用:{totalCost}";
    }
}
public class CustomerController
{
    private readonly StatisticsCalculator _calculator;
    private readonly IDeliveryGateway _gateway;

    public CustomerController(
        StatisticsCalculator calculator,   
        IDeliveryGateway gateway)          
    {
        _calculator = calculator;
        _gateway = gateway;
    }

    public string GetStatistics(int customerId)
    {
        var records = _gateway.GetDeliveries(customerId);
        (double totalWeight, double totalCost) = _calculator
            .Calculate(records);

        return
            $"Total weight delivered: {totalWeight}. " +
            $"Total cost: {totalCost}";
    }
}

两个独立的依赖

Two separate dependencies

与非托管依赖项通信的责任已转移到DeliveryGateway. 请注意此网关如何由接口支持,您现在可以使用该接口代替具体类进行模拟。清单11.15中的代码是 Humble Object 设计模式的一个例子。请参阅第 7 章以了解有关此模式的更多信息。

The responsibility of communicating with the unmanaged dependency has transitioned to DeliveryGateway. Notice how this gateway is backed by an interface, which you can now use for mocking instead of the concrete class. The code in listing 11.15 is an example of the Humble Object design pattern in action. Refer to chapter 7 to learn more about this pattern.

11.6 处理时间

11.6  Working with time

许多应用程序功能需要访问当前日期和时间。但是,依赖于时间的测试功能可能会导致误报:act 阶段的时间可能与 assert 中的时间不同。关于如何稳定这种依赖性,有三种选择。其中一个选项是反模式,而在另外两个选项中,一个比另一个更可取。

Many application features require access to the current date and time. Testing functionality that depends on time can result in false positives though: the time during the act phase might not be the same as in the assert. There are three options as to how you can stabilize this dependency. One of these options is an anti-pattern, and of the other two, one is more preferable than the other.

11.6.1 时间作为环境语境

11.6.1  Time as an ambient context

第一个选项是使用环境上下文模式。您已经在第 8 章有关测试记录器的部分中看到了这种模式。在时间的上下文中,环境上下文将是您在代码中使用的自定义类,而不是框架的内置DateTime.Now.

The first option is to use the ambient context pattern. You already saw this pattern in chapter 8 in the section about testing loggers. In the context of time, the ambient context would be a custom class that you’d use in code instead of the framework’s built-in DateTime.Now.

清单 11.16。当前日期和时间作为环境上下文

Listing 11.16. The current date and time as an ambient context

公共静态类 DateTimeServer
{
    私有静态 Func<DateTime> _func;
    public static DateTime Now => _func();

    public static void Init(Func<DateTime> func)
    {
        _func = 函数;
    }
}

DateTimeServer.Init(() => DateTime.Now);   

DateTimeServer.Init(() => new DateTime(2020, 1, 1));  
public static class DateTimeServer
{
    private static Func<DateTime> _func;
    public static DateTime Now => _func();

    public static void Init(Func<DateTime> func)
    {
        _func = func;
    }
}

DateTimeServer.Init(() => DateTime.Now);   

DateTimeServer.Init(() => new DateTime(2020, 1, 1));  

生产初始化代码

Initialization code for production

单元测试的初始化代码

Initialization code for unit tests

就像记录器功能一样,为时间使用环境上下文也是一种反模式。环境上下文污染了生产代码并使测试更加困难。静态字段引入了测试之间共享的依赖关系,从而将这些测试转换为集成测试领域。

Just like with the logger functionality, using an ambient context for time is also an anti-pattern. The ambient context pollutes the production code and makes testing more difficult. The static field introduces a dependency shared between tests, thus transitioning those tests into the sphere of integration testing.

11.6.2 时间作为显式依赖

11.6.2  Time as an explicit dependency

更好的方法是显式注入时间依赖性(而不是通过环境上下文中的静态方法引用它),作为服务或作为普通值。

A better approach is to inject the time dependency explicitly (instead of referring to it via a static method in an ambient context), either as a service or as a plain value.

清单 11.17。当前日期和时间作为显式依赖项

Listing 11.17. The current date and time as an explicit dependency

公共接口 IDateTimeServer
{
    日期时间现在 { 得到; }
}

公共类 DateTimeServer : IDateTimeServer
{
    public DateTime Now => DateTime.Now;
}

公共类 InquiryController
{
    私有只读 DateTimeServer _dateTimeServer;

    公共查询控制器(
        日期时间服务器 dateTimeServer)   
    {
        _dateTimeServer = 日期时间服务器;
    }

    public void ApproveInquiry(int id)
    {
        查询查询 = GetById(id);
        inquiry.Approve(_dateTimeServer.Now);   
        保存查询(查询);
    }
}
public interface IDateTimeServer
{
    DateTime Now { get; }
}

public class DateTimeServer : IDateTimeServer
{
    public DateTime Now => DateTime.Now;
}

public class InquiryController
{
    private readonly DateTimeServer _dateTimeServer;

    public InquiryController(
        DateTimeServer dateTimeServer)   
    {
        _dateTimeServer = dateTimeServer;
    }

    public void ApproveInquiry(int id)
    {
        Inquiry inquiry = GetById(id);
        inquiry.Approve(_dateTimeServer.Now);   
        SaveInquiry(inquiry);
    }
}

注入时间即服务

Injecting time as a service

将时间作为普通值注入

Injecting time as a plain value

在这两个选项中,更喜欢将时间作为价值而不是服务来注入。在生产代码中使用纯值更容易,在测试中存根这些值也更容易。

Of these two options, prefer injecting the time as a value rather than as a service. It’s easier to work with plain values in production code, and it’s also easier to stub those values in tests.

最有可能的是,您无法始终将时间作为普通值注入,因为依赖注入框架不能很好地处理值对象。一个好的折衷方案是在业务操作开始时将时间作为服务注入,然后在该操作的其余部分将其作为值传递。您可以在清单11.17中看到这种方法:控制器接受DateTimeServer(服务)但随后将DateTime值传递给Inquiry域类。

Most likely, you won’t be able to always inject the time as a plain value because dependency injection frameworks don’t play well with value objects. A good compromise is to inject the time as a service at the start of a business operation and then pass it as a value in the remainder of that operation. You can see this approach in listing 11.17: the controller accepts DateTimeServer (the service) but then passes a DateTime value to the Inquiry domain class.

11.6.3 结论

11.6.3  Conclusion

在本章中,我们研究了一些最突出的现实世界单元测试用例,并使用良好测试的四个属性对它们进行了分析。我知道立即开始应用本书中的所有想法和指南可能会让人不知所措。另外,您的情况可能不那么明确。我在我的博客https://enterprisecraftsmanship.com上发布对其他人代码的评论并回答问题(与单元测试和代码设计相关)。您也可以在https://enterprisecraftsmanship.com/about提交您自己的问题。

In this chapter, we looked at some of the most prominent real-world unit testing use cases and analyzed them using the four attributes of a good test. I understand that it may be overwhelming to start applying all the ideas and guidelines from this book at once. Also, your situation might not be as clear-cut. I publish reviews of other people’s code and answer questions (related to unit testing and code design in general) on my blog at https://enterprisecraftsmanship.com. You can also submit your own question at https://enterprisecraftsmanship.com/about.

您可能也有兴趣参加我的在线课程,在该课程中,我展示了如何从头开始构建应用程序,并在实践中应用本书中描述的所有原则,网址为https://unittestingcourse.com

You might also be interested in taking my online course where I show how to build an application from the ground up, applying all the principles described in this book in practice, at https://unittestingcourse.com.

您可以随时在 Twitter 上关注我@vkhorikov,或者直接通过https://enterprisecraftsmanship.com/about与我联系。

You can always catch me on twitter at @vkhorikov, or just contact me directly through https://enterprisecraftsmanship.com/about.

我期待着您的回音!

I look forward to hearing from you!

11.7 总结

11.7  Summary

  • 公开私有方法以启用单元测试会导致测试与实现耦合,并最终破坏测试对重构的抵抗力。不要直接测试私有方法,而是将它们作为总体可观察行为的一部分进行间接测试。

  • Exposing private methods to enable unit testing leads to coupling tests to implementation and, ultimately, damaging the tests' resistance to refactoring. Instead of testing private methods directly, test them indirectly as part of the overarching observable behavior.

  • 如果私有方法太复杂而无法作为使用它的公共 API 的一部分进行测试,则表明缺少抽象。将此抽象提取到一个单独的类中,而不是将私有方法公开。

  • If the private method is too complex to be tested as part of the public API that uses it, that’s an indication of a missing abstraction. Extract this abstraction into a separate class instead of making the private method public.

  • 在极少数情况下,私有方法确实属于类的可观察行为。此类方法通常在类与 ORM 或工厂之间实现非公共契约。

  • In rare cases, private methods do belong to the class’s observable behavior. Such methods usually implement a non-public contract between the class and an ORM or a factory.

  • 不要为了单元测试的唯一目的而公开您本来会保密的状态。您的测试应该以与生产代码完全相同的方式与被测系统交互;他们不应该有任何特权。

  • Don’t expose state that you would otherwise keep private for the sole purpose of unit testing. Your tests should interact with the system under test exactly the same way as the production code; they shouldn’t have any special privileges.

  • 编写测试时不要暗示任何特定的实现。从黑盒角度验证生产代码;避免将领域知识泄露给测试(有关黑盒和白盒测试的更多详细信息,请参阅第 4 章)。

  • Don’t imply any specific implementation when writing tests. Verify the production code from a black-box perspective; avoid leaking domain knowledge to tests (see chapter 4 for more details about black-box and white-box testing).

  • 代码污染是添加仅用于测试的生产代码。这是一种反模式,因为它混合了测试代码和生产代码并增加了后者的维护成本。

  • Code pollution is adding production code that’s only needed for testing. It’s an anti-pattern because it mixes up test and production code and increases the maintenance costs of the latter.

  • 为了保留其部分功能而模拟具体类的必要性是违反单一职责原则的结果。将该类分成两部分:一个与域逻辑相关,另一个与进程外依赖项通信。

  • The necessity to mock a concrete class in order to preserve part of its functionality is a result of violating the Single Responsibility principle. Separate that class in two: one with the domain logic, and the other one communicating with the out-of-process dependency.

  • 将当前时间表示为环境上下文会污染生产代码并使测试更加困难。将时间作为显式依赖项注入——作为服务或作为普通值。尽可能选择普通值。

  • Representing the current time as an ambient context pollutes the production code and makes testing more difficult. Inject time as an explicit dependency—either as a service or as a plain value. Prefer the plain value whenever possible.